summaryrefslogtreecommitdiff
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build-and-deploy-prod.yml2
-rw-r--r--.github/workflows/build-and-deploy-stage.yml2
-rw-r--r--Dockerfile4
-rw-r--r--config/config.go19
-rw-r--r--config/config.local.yaml5
-rw-r--r--config/config.prod.yaml4
-rw-r--r--config/config.stage.yaml4
-rw-r--r--deploy/playbook.yml1
-rw-r--r--go.mod31
-rw-r--r--go.sum71
-rw-r--r--internal/b2/client.go7
-rw-r--r--internal/blogtrigger/blogtrigger.go72
-rw-r--r--internal/glightbox/html_renderer.go11
-rw-r--r--internal/mailer/mailer.go279
-rw-r--r--internal/router/api-v1-blog-search.go128
-rw-r--r--internal/router/api-v1-email-is-in-verification.go58
-rw-r--r--internal/router/api-v1-email-send-verification-code.go98
-rw-r--r--internal/router/api-v1-email-verify.go52
-rw-r--r--internal/router/api-v1-general-page-body.go206
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go86
-rw-r--r--internal/router/api-v1-general-page-footer.go86
-rw-r--r--internal/router/api-v1-general-page-header.go98
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go86
-rw-r--r--internal/router/api-v1-general-page.go59
-rw-r--r--internal/router/api-v1-like.go122
-rw-r--r--internal/router/api-v1-tz.go37
-rw-r--r--internal/router/basic-handler.go62
-rw-r--r--internal/router/client-cache.go20
-rw-r--r--internal/router/handlers/api-v1-blog-search.go126
-rw-r--r--internal/router/handlers/api-v1-email-is-in-verification.go59
-rw-r--r--internal/router/handlers/api-v1-email-send-verification-code.go97
-rw-r--r--internal/router/handlers/api-v1-email-verify.go62
-rw-r--r--internal/router/handlers/api-v1-like-get.go63
-rw-r--r--internal/router/handlers/api-v1-like-put.go74
-rw-r--r--internal/router/handlers/api-v1-subs-put.go65
-rw-r--r--internal/router/handlers/api-v1-tz-get.go65
-rw-r--r--internal/router/handlers/lang-blog-title.go112
-rw-r--r--internal/router/handlers/lang-blog.go112
-rw-r--r--internal/router/handlers/lang-map.go95
-rw-r--r--internal/router/handlers/lang-user-unsubscribe.go68
-rw-r--r--internal/router/handlers/lang-user.go78
-rw-r--r--internal/router/handlers/lang.go56
-rw-r--r--internal/router/handlers/root.go47
-rw-r--r--internal/router/lang-map.go88
-rw-r--r--internal/router/page-cache.go5
-rw-r--r--internal/router/path-matcher.go48
-rw-r--r--internal/router/router.go564
-rw-r--r--internal/router/tm.go18
-rw-r--r--internal/templatemanager/templatemanager.go30
-rw-r--r--locale/localization.en.yaml22
-rw-r--r--locale/localization.go43
-rw-r--r--locale/localization.ru.yaml20
-rw-r--r--main.go167
-rw-r--r--static/input.css207
-rw-r--r--views/layouts/general-mail.html41
-rw-r--r--views/layouts/general-page.html81
-rw-r--r--views/messages/new-post.html20
-rw-r--r--views/pages/blog-catalogue.html109
-rw-r--r--views/pages/blog-page.html23
-rw-r--r--views/pages/global-map.html40
-rw-r--r--views/pages/home-page.html19
-rw-r--r--views/pages/language-pick.html2
-rw-r--r--views/pages/unsubscribe-page.html30
-rw-r--r--views/pages/user-page.html130
-rw-r--r--views/partials/blog-page-like-button.html9
-rw-r--r--views/partials/catalogue-blog-cards.html2
-rw-r--r--views/partials/general-page-body.html1
-rw-r--r--views/partials/general-page-header.html11
68 files changed, 1673 insertions, 2946 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml
index 413f402..d57e872 100644
--- a/.github/workflows/build-and-deploy-prod.yml
+++ b/.github/workflows/build-and-deploy-prod.yml
@@ -12,7 +12,7 @@ jobs:
packages: write
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v4
- name: Generate output.css with tailwindcss
uses: ZoeyVid/tailwindcss-update@main
with:
diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml
index c6e222b..16e6563 100644
--- a/.github/workflows/build-and-deploy-stage.yml
+++ b/.github/workflows/build-and-deploy-stage.yml
@@ -11,7 +11,7 @@ jobs:
packages: write
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v4
- name: Generate output.css with tailwindcss
uses: ZoeyVid/tailwindcss-update@main
diff --git a/Dockerfile b/Dockerfile
index 7230149..eeee6c5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25.3-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
@@ -6,7 +6,7 @@ WORKDIR /builddir
COPY . .
RUN CGO_ENABLED=1 go build -o sayana-web .
-FROM alpine:3.22.2 AS runtime-stage
+FROM alpine:3.22.1 AS runtime-stage
ENV ENVIRONMENT=""
ENV AUTH_SALT=""
diff --git a/config/config.go b/config/config.go
index 1393dd8..d5dab2d 100644
--- a/config/config.go
+++ b/config/config.go
@@ -62,18 +62,13 @@ type Sqlite3Config struct {
}
type MailConfig struct {
- ClientHost string `json:"ClientHost" yaml:"clientHost" validate:"required"`
- MailHost string `json:"MailHost" yaml:"mailHost" validate:"required"`
- PublicName string `json:"PublicName" yaml:"publicName" validate:"required"`
- MailAddress string `json:"MailAddress" yaml:"mailAddress" validate:"required"`
- Username string `json:"Username" yaml:"username" validate:"required"`
- Password string `json:"Password" yaml:"password" validate:"required"`
- Salt string `json:"Salt" yaml:"salt" validate:"required"`
- Trigger TriggerConfig `json:"Trigger" yaml:"trigger" validate:"required"`
-}
-
-type TriggerConfig struct {
- OnNewPost string `json:"OnNewPost" yaml:"onNewPost" validate:"cron,required"`
+ ClientHost string `json:"ClientHost" yaml:"clientHost" validate:"required"`
+ MailHost string `json:"MailHost" yaml:"mailHost" validate:"required"`
+ PublicName string `json:"PublicName" yaml:"publicName" validate:"required"`
+ MailAddress string `json:"MailAddress" yaml:"mailAddress" validate:"required"`
+ Username string `json:"Username" yaml:"username" validate:"required"`
+ Password string `json:"Password" yaml:"password" validate:"required"`
+ Salt string `json:"Salt" yaml:"salt" validate:"required"`
}
func LoadConfig(path string, config *Config) error {
diff --git a/config/config.local.yaml b/config/config.local.yaml
index e23c808..008fa93 100644
--- a/config/config.local.yaml
+++ b/config/config.local.yaml
@@ -32,8 +32,7 @@ auth:
db:
type: sqlite3
config:
- # dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc&_journal_mode=WAL'
- dsn: 'file:/tmp/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL'
+ dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc'
salt: '123'
mail:
clientHost: '127.0.0.1:3000'
@@ -43,5 +42,3 @@ mail:
username: '${MAIL_USERNAME}'
password: '${MAIL_PASSWORD}'
salt: '${MAIL_SALT}'
- trigger:
- onNewPost: "* * * * *"
diff --git a/config/config.prod.yaml b/config/config.prod.yaml
index 0bee79d..4c0f425 100644
--- a/config/config.prod.yaml
+++ b/config/config.prod.yaml
@@ -32,7 +32,7 @@ auth:
db:
type: sqlite3
config:
- dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL'
+ dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2'
salt: '${AUTH_SALT}'
mail:
clientHost: '${FQDN}'
@@ -42,5 +42,3 @@ mail:
username: '${MAIL_USERNAME}'
password: '${MAIL_PASSWORD}'
salt: '${MAIL_SALT}'
- trigger:
- onNewPost: "0/5 * * * *"
diff --git a/config/config.stage.yaml b/config/config.stage.yaml
index 1c15dae..003ec05 100644
--- a/config/config.stage.yaml
+++ b/config/config.stage.yaml
@@ -32,7 +32,7 @@ auth:
db:
type: sqlite3
config:
- dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL'
+ dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2'
salt: '${AUTH_SALT}'
mail:
clientHost: '${FQDN}'
@@ -42,5 +42,3 @@ mail:
username: '${MAIL_USERNAME}'
password: '${MAIL_PASSWORD}'
salt: '${MAIL_SALT}'
- trigger:
- onNewPost: "0/3 * * * *"
diff --git a/deploy/playbook.yml b/deploy/playbook.yml
index 2516f4e..ff54078 100644
--- a/deploy/playbook.yml
+++ b/deploy/playbook.yml
@@ -19,7 +19,6 @@
ENVIRONMENT: "{{ saya_today_web_environment }}"
AUTH_SALT: "{{ saya_today_web_auth_salt }}"
MAIL_HOST: "{{ saya_today_web_mail_host }}"
- MAIL_ADDRESS: "{{ saya_today_web_mail_address }}"
MAIL_USERNAME: "{{ saya_today_web_mail_username }}"
MAIL_PASSWORD: "{{ saya_today_web_mail_password }}"
MAIL_SALT: "{{ saya_today_web_mail_salt }}"
diff --git a/go.mod b/go.mod
index 83f1af9..6b86f6b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,27 +1,22 @@
module github.com/SayaAndy/saya-today-web
-go 1.25
+go 1.24.6
require (
github.com/Backblaze/blazer v0.7.2
- github.com/dgraph-io/ristretto/v2 v2.3.0
- github.com/go-co-op/gocron/v2 v2.17.0
- github.com/go-playground/validator/v10 v10.28.0
+ github.com/go-playground/validator/v10 v10.27.0
github.com/gofiber/fiber/v2 v2.52.9
- github.com/golang-migrate/migrate/v4 v4.19.0
+ github.com/golang-migrate/migrate/v4 v4.18.3
github.com/mattn/go-sqlite3 v1.14.32
- github.com/valyala/fasthttp v1.68.0
- github.com/wneessen/go-mail v0.7.2
github.com/yuin/goldmark v1.7.13
- golang.org/x/crypto v0.43.0
+ golang.org/x/crypto v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/clipperhouse/stringish v0.1.1 // indirect
- github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
+ github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -29,15 +24,17 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
- github.com/jonboulle/clockwork v0.5.0 // indirect
- github.com/klauspost/compress v1.18.1 // indirect
- github.com/kr/pretty v0.3.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.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.19 // indirect
- github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // 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
- golang.org/x/sys v0.37.0 // indirect
- golang.org/x/text v0.30.0 // indirect
+ github.com/valyala/fasthttp v1.65.0 // indirect
+ github.com/wneessen/go-mail v0.7.2 // indirect
+ go.uber.org/atomic v1.7.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
)
diff --git a/go.sum b/go.sum
index a95204e..a49ee2c 100644
--- a/go.sum
+++ b/go.sum
@@ -4,35 +4,27 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
-github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
-github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+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/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
-github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
-github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
-github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
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=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
-github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+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/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
-github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
+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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -40,14 +32,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
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/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
-github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
-github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
-github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -56,40 +42,41 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
-github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+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/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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
-github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+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.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
-github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
+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/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
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=
-go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
-go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+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=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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/b2/client.go b/internal/b2/client.go
index d712736..7b745a8 100644
--- a/internal/b2/client.go
+++ b/internal/b2/client.go
@@ -3,7 +3,6 @@ package b2
import (
"context"
"fmt"
- "slices"
"strings"
"time"
@@ -35,7 +34,6 @@ func NewB2Client(cfg *config.B2Config) (*B2Client, error) {
type BlogPage struct {
Link string
FileName string
- Lang string
Metadata *frontmatter.Metadata
}
@@ -76,9 +74,6 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
nameParts := strings.Split(linkParts[len(linkParts)-1], ".")
fileName := strings.Join(nameParts[:len(linkParts)-1], ".")
- tags := strings.Split(attrs.Info["tags"], ",")
- slices.Sort(tags)
-
filePaths = append(filePaths, &BlogPage{
Link: obj.Name(),
FileName: fileName,
@@ -88,7 +83,7 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
ActionDate: attrs.Info["action-date"],
PublishedTime: publishedTime,
Thumbnail: attrs.Info["thumbnail"],
- Tags: tags,
+ Tags: strings.Split(attrs.Info["tags"], ","),
Geolocation: attrs.Info["geolocation"],
},
})
diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go
deleted file mode 100644
index c5b3097..0000000
--- a/internal/blogtrigger/blogtrigger.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package blogtrigger
-
-import (
- "fmt"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/go-co-op/gocron/v2"
-)
-
-type BlogTriggerScheduler struct {
- s gocron.Scheduler
- knownBlogPages map[string]map[string]*b2.BlogPage
- b2Client *b2.B2Client
- onTrigger func([]*b2.BlogPage) error
-}
-
-func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, error) {
- s, err := gocron.NewScheduler()
- if err != nil {
- return nil, fmt.Errorf("failed to create new scheduler: %w", err)
- }
-
- knownBlogPages := make(map[string]map[string]*b2.BlogPage, len(availableLanguages))
- for _, lang := range availableLanguages {
- knownBlogPages[lang.Name] = make(map[string]*b2.BlogPage)
- }
-
- bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger}
- defer bts.s.Start()
-
- bts.s.NewJob(gocron.CronJob(cron, false), gocron.NewTask(func(bts *BlogTriggerScheduler) {
- posts, err := bts.scan()
- if err != nil {
- slog.Error("failed to execute scanning new blog pages cron job", slog.String("error", err.Error()))
- return
- }
- if err = onTrigger(posts); err != nil {
- slog.Error("error happened on callback function after scanning new blog pages", slog.String("error", err.Error()))
- return
- }
- }, bts))
-
- if _, err = bts.scan(); err != nil {
- return nil, fmt.Errorf("failed to scan existing blog pages in b2: %w", err)
- }
-
- return bts, nil
-}
-
-func (bts *BlogTriggerScheduler) Close() error {
- return bts.s.Shutdown()
-}
-
-func (bts *BlogTriggerScheduler) scan() (newPages []*b2.BlogPage, err error) {
- newPages = make([]*b2.BlogPage, 0)
- for lang := range bts.knownBlogPages {
- posts, err := bts.b2Client.Scan(lang + "/")
- if err != nil {
- return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err)
- }
- for _, post := range posts {
- if _, ok := bts.knownBlogPages[lang][post.FileName]; !ok {
- post.Lang = lang
- newPages = append(newPages, post)
- bts.knownBlogPages[lang][post.FileName] = post
- }
- }
- }
- return newPages, nil
-}
diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go
index b7b256a..7022793 100644
--- a/internal/glightbox/html_renderer.go
+++ b/internal/glightbox/html_renderer.go
@@ -133,9 +133,9 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte,
}
w.WriteString(fmt.Sprintf(`
-<div class="items-center flex flex-col">
+<div class="justify-content-center display-block">
<hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto">
- <div class="grid masonry-grid-%s">
+ <div class="grid masonry-grid-%s mx-auto">
<div class="grid-sizer grid-sizer-%s"></div>
%s
</div>
@@ -144,10 +144,11 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte,
w.WriteString(fmt.Sprintf(`
<script>
- var pckry_%s = new Packery('.masonry-grid-%s', {
+ var msnry_%s = new Masonry('.masonry-grid-%s', {
itemSelector: '.grid-item-%s',
columnWidth: '.grid-sizer-%s',
- percentPosition: false
+ percentPosition: false,
+ fitWidth: true
});
var imgLoad_%s_timer;
@@ -155,7 +156,7 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte,
function initMasonryLayout_%s(tm) {
clearTimeout(imgLoad_%s_timer);
- imgLoad_%s_timer = setTimeout(() => pckry_%s.layout(), 100);
+ imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100);
}
imgLoad_%s.on('progress', initMasonryLayout_%s);
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
index d6dbd17..450b818 100644
--- a/internal/mailer/mailer.go
+++ b/internal/mailer/mailer.go
@@ -6,15 +6,12 @@ import (
"encoding/base64"
"encoding/binary"
"fmt"
- "html/template"
"log/slog"
- "slices"
"strconv"
"strings"
"sync"
"time"
- "github.com/SayaAndy/saya-today-web/internal/b2"
"github.com/SayaAndy/saya-today-web/internal/templatemanager"
"github.com/SayaAndy/saya-today-web/locale"
"github.com/dgraph-io/ristretto/v2"
@@ -25,7 +22,6 @@ import (
type Mailer struct {
verificationCodes *ristretto.Cache[uint64, string]
- unsubscribeCodes *ristretto.Cache[uint64, []byte]
db *sql.DB
tm *templatemanager.TemplateManager
mailClient *mail.Client
@@ -47,30 +43,12 @@ type Mailer struct {
l map[string]*locale.LocaleConfig
}
-type SubscriptionType int
-
-const (
- All SubscriptionType = iota
- None
- Specific
-)
-
func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) {
verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, string]{
NumCounters: 10000,
MaxCost: 1 << 20, // 1 MB
BufferItems: 64,
- TtlTickerDurationInSec: 3600, // 1 hour
- })
- if err != nil {
- return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err)
- }
-
- unsubscribeCodes, err := ristretto.NewCache(&ristretto.Config[uint64, []byte]{
- NumCounters: 10000,
- MaxCost: 1 << 20, // 1 MB
- BufferItems: 64,
- TtlTickerDurationInSec: 86400, // 1 day
+ TtlTickerDurationInSec: 3600,
})
if err != nil {
return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err)
@@ -97,7 +75,6 @@ func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string
return &Mailer{
verificationCodes: verificationCodes,
- unsubscribeCodes: unsubscribeCodes,
db: db,
clientHost: clientHost,
tm: tm,
@@ -152,45 +129,31 @@ func (m *Mailer) MailIsTaken(email string) (bool, error) {
return false, fmt.Errorf("failed to initialize transaction with db: %s", err)
}
- slog.Debug("began db transaction", slog.String("method", "MailIsTaken"))
- defer func(tx *sql.Tx) {
- if err = tx.Commit(); err != nil {
- tx.Rollback()
- }
- slog.Debug("ended db transaction", slog.String("method", "MailIsTaken"))
- }(tx)
-
var rows *sql.Rows
if rows, err = tx.Query(`SELECT email FROM user_email_table WHERE email=? LIMIT 1;`, email); err != nil {
tx.Rollback()
-
return false, fmt.Errorf("failed to query user-email settings in db: %s", err)
}
+ defer tx.Commit()
defer rows.Close()
- isTaken := rows.Next()
- return isTaken, nil
+ return rows.Next(), nil
}
-func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err error) {
+func (m *Mailer) GetInfo(userId string) (email string, lang string, err error) {
+ hash := m.GetHash(userId)
+
tx, err := m.db.Begin()
if err != nil {
return "", "", fmt.Errorf("failed to initialize transaction with db: %s", err)
}
- slog.Debug("began db transaction", slog.String("method", "GetInfo"))
- defer func(tx *sql.Tx) {
- if err = tx.Commit(); err != nil {
- tx.Rollback()
- }
- slog.Debug("ended db transaction", slog.String("method", "GetInfo"))
- }(tx)
-
var rows *sql.Rows
- if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, userIdHash); err != nil {
+ if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, hash); err != nil {
tx.Rollback()
return "", "", fmt.Errorf("failed to query user-email settings in db: %s", err)
}
+ defer tx.Commit()
defer rows.Close()
if !rows.Next() {
@@ -203,24 +166,6 @@ func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err erro
return
}
-func (m *Mailer) Unsubscribe(unsubscribeCodeString string) (clientError error, serverError error) {
- unsubscribeCode, err := strconv.ParseUint(unsubscribeCodeString, 16, 64)
- if err != nil {
- return fmt.Errorf("invalid unsubscribe code: %s", err), nil
- }
-
- userId, _ := m.unsubscribeCodes.Get(unsubscribeCode)
- if len(userId) == 0 {
- return fmt.Errorf("invalid unsubscribe code: have no information about it"), nil
- }
-
- if err = m.Subscribe(userId, None); err != nil {
- return nil, fmt.Errorf("failed to unsubscribe: %s", err)
- }
-
- return nil, nil
-}
-
func (m *Mailer) SendVerificationCode(userId string, address string, lang string) error {
message := mail.NewMsg()
@@ -317,228 +262,20 @@ func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error {
return fmt.Errorf("failed to initialize transaction with db: %s", err)
}
- slog.Debug("began db transaction", slog.String("method", "Verify"))
if _, err = tx.Exec(`INSERT INTO user_email_table(user_id, email, lang) VALUES(?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
email=excluded.email,
lang=excluded.lang;`, m.GetHash(string(userId)), address, lang); err != nil {
tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "Verify"))
return fmt.Errorf("failed to configure user-email settings in db: %s", err)
}
if err = tx.Commit(); err != nil {
tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "Verify"))
return fmt.Errorf("failed to commit transaction to db: %s", err)
}
- slog.Debug("ended db transaction", slog.String("method", "Verify"))
m.verificationCodes.Del(verificationCode)
delete(m.lostMailMap, verificationSegments[0])
return nil
}
-
-func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionType, tags []string, err error) {
- tx, err := m.db.Begin()
- if err != nil {
- return None, nil, fmt.Errorf("failed to initialize transaction with db: %s", err)
- }
-
- slog.Debug("began db transaction", slog.String("method", "GetSubscriptions"))
- defer func(tx *sql.Tx) {
- if err = tx.Commit(); err != nil {
- tx.Rollback()
- }
- slog.Debug("ended db transaction", slog.String("method", "GetSubscriptions"))
- }(tx)
-
- hash := m.GetHash(userId)
-
- var rows *sql.Rows
- if rows, err = tx.Query(`SELECT tags FROM subscription_user_to_tags_table WHERE user_id=? LIMIT 1;`, hash); err != nil {
- tx.Rollback()
- return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err)
- }
- defer rows.Close()
-
- if !rows.Next() {
- return None, nil, nil
- }
-
- tagsString := ""
- if err = rows.Scan(&tagsString); err != nil {
- return None, nil, fmt.Errorf("failed to scan the result from user-to-tags query: %s", err)
- }
-
- switch tagsString {
- case "":
- return None, nil, nil
- case "_all":
- return All, nil, nil
- default:
- return Specific, strings.Split(tagsString, ","), nil
- }
-}
-
-func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, tags ...string) error {
- tx, err := m.db.Begin()
- if err != nil {
- return fmt.Errorf("failed to initialize transaction with db: %s", err)
- }
-
- slog.Debug("began db transaction", slog.String("method", "Subscribe"))
-
- slices.Sort(tags)
- tagsOutput := ""
- switch subscriptionType {
- case All:
- tagsOutput = "_all"
- case None:
- tagsOutput = ""
- case Specific:
- tagsOutput = strings.Join(tags, ",")
- }
-
- if _, err = tx.Exec(`INSERT INTO subscription_user_to_tags_table(user_id, tags) VALUES(?, ?)
- ON CONFLICT(user_id) DO UPDATE SET
- tags=excluded.tags;`, userIdHash, tagsOutput); err != nil {
- tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "Subscribe"))
- return fmt.Errorf("failed to configure user-to-tags table in db for the user: %s", err)
- }
-
- if err = tx.Commit(); err != nil {
- tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "Subscribe"))
- return fmt.Errorf("failed to commit transaction to db: %s", err)
- }
- slog.Debug("ended db transaction", slog.String("method", "Subscribe"))
-
- return nil
-}
-
-func (m *Mailer) NewPost(post *b2.BlogPage) error {
- tx, err := m.db.Begin()
- if err != nil {
- return fmt.Errorf("failed to initialize transaction with db: %s", err)
- }
-
- slog.Debug("began db transaction", slog.String("method", "NewPost"))
- var rows *sql.Rows
- if rows, err = tx.Query(`SELECT user_id, tags FROM subscription_user_to_tags_table;`); err != nil {
- tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "NewPost"))
- return fmt.Errorf("failed to query user-to-tags table in db: %s", err)
- }
-
- var userId []byte
- usersToSend := make([]struct {
- userId []byte
- email string
- }, 0)
- var tagsString string
- i := -1
-
-rowLoop:
- for rows.Next() {
- i++
- if err = rows.Scan(&userId, &tagsString); err != nil {
- slog.Warn("failed to scan a row in user-to-tags table", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId)))
- continue
- }
-
- if tagsString == "" {
- continue
- }
-
- if tagsString == "_all" {
- usersToSend = append(usersToSend, struct {
- userId []byte
- email string
- }{userId, ""})
- continue
- }
-
- pageTags := strings.Split(tagsString, ",")
- for _, tag := range pageTags {
- if _, found := slices.BinarySearch(post.Metadata.Tags, tag); found {
- usersToSend = append(usersToSend, struct {
- userId []byte
- email string
- }{userId, ""})
- continue rowLoop
- }
- }
- }
-
- tx.Commit()
- rows.Close()
- slog.Debug("ended db transaction", slog.String("method", "NewPost"))
-
- for i := range usersToSend {
- email, lang, err := m.GetInfo(usersToSend[i].userId)
- if err != nil {
- slog.Warn("failed to get info about the user", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId)))
- continue
- }
-
- if lang != post.Lang {
- continue
- }
-
- usersToSend[i].email = email
- }
-
- messages := make([]*mail.Msg, 0, len(usersToSend))
- for _, user := range usersToSend {
- if user.email == "" {
- continue
- }
- unsubscribeCodeBytes := make([]byte, 8)
- rand.Read(unsubscribeCodeBytes)
- unsubscribeCode := binary.LittleEndian.Uint64(unsubscribeCodeBytes)
-
- unsubscribeFooter := strings.Replace(m.l[post.Lang].Mail.UnsubscribeFooter, "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1)
- unsubscribeFooter = strings.Replace(unsubscribeFooter, "{/}", "</a>", 1)
-
- msgBody, err := m.tm.Render("new-post", fiber.Map{
- "L": m.l[post.Lang],
- "Lang": post.Lang,
- "Post": post,
- "ClientHost": m.clientHost,
- "UnsubscribeFooter": template.HTML(unsubscribeFooter),
- })
-
- if err != nil {
- return fmt.Errorf("failed to render message body: %w", err)
- }
-
- message := mail.NewMsg()
-
- if err := message.EnvelopeFrom(m.mailAddress); err != nil {
- return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err)
- }
- if err := message.FromFormat(m.publicName, m.mailAddress); err != nil {
- return fmt.Errorf("failed to set formatted FROM address: %w", err)
- }
- if err := message.To(user.email); err != nil {
- return fmt.Errorf("failed to set TO address: %w", err)
- }
-
- message.SetMessageID()
- message.SetDate()
- message.SetBulk()
- message.Subject(m.l[post.Lang].Mail.NewPost.Subject)
- message.SetBodyString(mail.TypeTextHTML, string(msgBody))
-
- m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40)
-
- messages = append(messages, message)
- }
-
- if err := m.mailClient.DialAndSend(messages...); err != nil {
- return fmt.Errorf("failed to send new post notifications: %w", err)
- }
- return nil
-}
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
new file mode 100644
index 0000000..5b1d14d
--- /dev/null
+++ b/internal/router/api-v1-blog-search.go
@@ -0,0 +1,128 @@
+package router
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"))
+}
+
+func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ sort := c.Query("sort")
+ lang := c.Query("lang")
+ tz := c.Query("tz")
+
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
+
+ langIsAvailable:
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ cacheKey := "blog-search." + lang + ".pages-list"
+ var pages []*b2.BlogPage
+ if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ pages, err = b2Client.Scan(lang + "/")
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err))
+ }
+ pagesBytes, _ := json.Marshal(pages)
+ PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute)
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ re, err := regexp.Compile(`tags\[\]=([\w]+)`)
+ if err != nil {
+ slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
+ }
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := re.FindAllStringSubmatch(decodedQuery, -1)
+
+ tags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ tags = append(tags, string(match[1]))
+ }
+
+ pageMeta := make([]fiber.Map, 0, len(pages))
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ if len(tags) == 0 || slices.Contains(tags, tag) {
+ pageMeta = append(pageMeta, fiber.Map{
+ "Link": page.Link,
+ "ArticleLink": "/" + lang + "/blog/" + page.FileName,
+ "Title": page.Metadata.Title,
+ "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
+ "ActionDate": page.Metadata.ActionDate,
+ "ShortDescription": page.Metadata.ShortDescription,
+ "Thumbnail": page.Metadata.Thumbnail,
+ "Tags": page.Metadata.Tags,
+ "LikeCount": CCache.GetLikeCount(page.FileName),
+ "ViewCount": CCache.GetViewCount(page.FileName),
+ })
+ break
+ }
+ }
+ }
+
+ slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
+ switch sort {
+ case "titleAsc":
+ return strings.Compare(a["Title"].(string), b["Title"].(string))
+ case "titleDesc":
+ return strings.Compare(b["Title"].(string), a["Title"].(string))
+ case "actionDateAsc":
+ return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
+ case "actionDateDesc":
+ return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
+ case "publicationDateAsc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeA.Compare(publishedTimeB)
+ case "publicationDateDesc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeB.Compare(publishedTimeA)
+ }
+ return 0
+ })
+
+ content, err := tm.Render("catalogue-blog-cards", fiber.Map{
+ "BlogPages": pageMeta,
+ "L": l[lang],
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ return c.Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-email-is-in-verification.go b/internal/router/api-v1-email-is-in-verification.go
new file mode 100644
index 0000000..83c783c
--- /dev/null
+++ b/internal/router/api-v1-email-is-in-verification.go
@@ -0,0 +1,58 @@
+package router
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Email_IsInVerification(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ id := c.IP()
+ lang := "en"
+
+ var referer, path string
+ var pathParts []string
+ var urlStruct *url.URL
+ var err error
+
+ referer = c.Get("Referer", "")
+ if referer == "" {
+ goto skipFetchingLang
+ }
+
+ urlStruct, err = url.ParseRequestURI(referer)
+ if err != nil {
+ goto skipFetchingLang
+ }
+
+ path = urlStruct.EscapedPath()
+ pathParts = strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ goto skipFetchingLang
+ }
+
+ lang = pathParts[0]
+
+ skipFetchingLang:
+ isAllowed, whenAllowed, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
+ if !isAllowed {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Neutral", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, "", map[string]string{
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+}
diff --git a/internal/router/api-v1-email-send-verification-code.go b/internal/router/api-v1-email-send-verification-code.go
new file mode 100644
index 0000000..2cde1bd
--- /dev/null
+++ b/internal/router/api-v1-email-send-verification-code.go
@@ -0,0 +1,98 @@
+package router
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Email_SendVerificationCode(l map[string]*locale.LocaleConfig) 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) != 2 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
+ }
+
+ lang := pathParts[0]
+ id := c.IP()
+
+ email := c.FormValue("email")
+ if email == "" {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailEmpty, map[string]string{})
+ }
+
+ isTaken, err := Mailer.MailIsTaken(email)
+ if err != nil {
+ slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
+ }
+ if isTaken {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailTaken, map[string]string{})
+ }
+
+ if isAllowed, whenAllowed, _ := Mailer.IsAllowedToRetryVerification(id); !isAllowed {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
+ })
+ }
+
+ if previousEmail, _, _ := Mailer.GetInfo(id); previousEmail == email {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailAlreadyValidated, map[string]string{})
+ }
+
+ if err = Mailer.SendVerificationCode(id, email, lang); err != nil {
+ slog.Error("failed to send a verification code", slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
+ }
+
+ _, endTime, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.VerificationCodeSent, "{}", endTime.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(endTime.UnixMilli()),
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+}
+
+func api_v1_email_sendStatusHtml(c *fiber.Ctx, divId string, l map[string]*locale.LocaleConfig, status string, code int, lang string, message string, dataAttributes map[string]string) error {
+ sterileDataset := make(map[string]interface{})
+ for k, v := range dataAttributes {
+ sterileDataset[k] = template.HTMLAttr(fmt.Sprintf("data-%s=\"%s\"", k, template.HTMLEscapeString(v)))
+ }
+
+ content, err := tm.Render("personal-page-status", fiber.Map{
+ "L": l[lang],
+ "Status": status,
+ "Message": message,
+ "StatusId": divId,
+ "DataAttributes": sterileDataset,
+ })
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ slog.Error("failed to render the email status message", slog.String("error", err.Error()), slog.String("div_id", divId), slog.String("message", message))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("Failed to render the email status message, please ask administrator for a more detailed cause")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(code).Send(content)
+}
diff --git a/internal/router/api-v1-email-verify.go b/internal/router/api-v1-email-verify.go
new file mode 100644
index 0000000..fad174b
--- /dev/null
+++ b/internal/router/api-v1-email-verify.go
@@ -0,0 +1,52 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Email_Verify(l map[string]*locale.LocaleConfig) 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) != 2 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
+ }
+
+ lang := pathParts[0]
+
+ verificationCode := c.FormValue("email_code")
+ if verificationCode == "" {
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationEmpty, map[string]string{})
+ }
+
+ if err = Mailer.Verify(verificationCode, lang); err != nil {
+ slog.Error("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationFailed, map[string]string{})
+ }
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.VerificationSuccess, map[string]string{
+ "hide-verification-panel": "true",
+ })
+ }
+}
diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go
new file mode 100644
index 0000000..6f856d3
--- /dev/null
+++ b/internal/router/api-v1-general-page-body.go
@@ -0,0 +1,206 @@
+package router
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "math/rand"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+ "github.com/yuin/goldmark"
+)
+
+var FactGiver *factgiver.FactGiver
+var Mailer *mailer.Mailer
+
+func init() {
+ assert(0, tm.Add("general-page-body", "views/partials/general-page-body.html"))
+}
+
+func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client, md goldmark.Markdown) 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()
+ trimmedPath := strings.Trim(path, "/")
+
+ cacheKey := fmt.Sprintf("body.%s", trimmedPath)
+ if val, ok := PCache.Get(cacheKey); !strings.HasSuffix(trimmedPath, "user") && val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(trimmedPath, "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ querySort := c.Query("sort")
+ if querySort == "" {
+ querySort = "publicationDateDesc"
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ re, err := regexp.Compile(`tags\[\]=([\w]+)`)
+ if err != nil {
+ slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
+ }
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := re.FindAllStringSubmatch(decodedQuery, -1)
+
+ queryTags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ queryTags = append(queryTags, string(match[1]))
+ }
+
+ pages, err := b2Client.Scan(lang + "/")
+ if err != nil {
+ slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages via b2: %s", slog.String("error", err.Error())))
+ }
+
+ tagsMap := make(map[string]int)
+ for _, page := range pages {
+ 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"`
+ Count int `json:"Count" yaml:"count"`
+ }
+
+ tagsArray := make([]Tag, 0, len(tagsMap))
+ for tag, count := range tagsMap {
+ tagsArray = append(tagsArray, Tag{tag, count})
+ }
+ slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ values["Tags"] = tagsArray
+ values["QuerySort"] = querySort
+ values["QueryTags"] = strings.Join(queryTags, ",")
+ values["Title"] = l[lang].BlogSearch.Header
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ values["Title"] = l[lang].UserProfile.Header
+
+ email, _, err := Mailer.GetInfo(c.IP())
+ if err != nil {
+ slog.Error("get info from mailer about a client", slog.String("error", err.Error()))
+ }
+ values["Email"] = email
+ values["EmailCode"] = c.Query("email_code")
+
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2]))
+ }
+
+ geolocationParts := strings.Split(metadata.Geolocation, " ")
+ var x, y, areaError string
+ if len(geolocationParts) >= 2 {
+ x = geolocationParts[0]
+ y = geolocationParts[1]
+ }
+ if len(geolocationParts) >= 3 {
+ areaError = geolocationParts[2]
+ }
+
+ values["MapLocationX"] = x
+ values["MapLocationY"] = y
+ values["MapLocationAreaMeters"] = areaError
+ values["Title"] = metadata.Title
+ values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+
+ 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)
+ values["OutlineHeartCount"] = uint(40)
+ values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
+ values["FunFacts"] = FactGiver.Give(lang)
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ values["Title"] = "Choose Your Language"
+ values["AvailableLanguages"] = langs
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-body", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
+
+func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
+ metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := md.Convert(markdown, &buf); err != nil {
+ return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
+ }
+
+ return metadata, buf.String(), nil
+}
diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go
new file mode 100644
index 0000000..b408187
--- /dev/null
+++ b/internal/router/api-v1-general-page-bottom-embeds.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html"))
+}
+
+func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *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()
+ cacheKey := fmt.Sprintf("bottom-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
new file mode 100644
index 0000000..99d899a
--- /dev/null
+++ b/internal/router/api-v1-general-page-footer.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-footer", "views/partials/general-page-footer.html"))
+}
+
+func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) 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()
+
+ cacheKey := fmt.Sprintf("footer.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-footer", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
new file mode 100644
index 0000000..768417d
--- /dev/null
+++ b/internal/router/api-v1-general-page-header.go
@@ -0,0 +1,98 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-header", "views/partials/general-page-header.html"))
+}
+
+func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *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()
+
+ cacheKey := fmt.Sprintf("header.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ values["Title"] = l[lang].BlogSearch.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ values["Title"] = l[lang].UserProfile.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path))
+ }
+ values["Title"] = metadata.Title
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ values["Title"] = "Choose Your Language"
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-header", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
new file mode 100644
index 0000000..f3e480b
--- /dev/null
+++ b/internal/router/api-v1-general-page-top-embeds.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html"))
+}
+
+func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) 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()
+
+ cacheKey := fmt.Sprintf("top-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
new file mode 100644
index 0000000..c8b4362
--- /dev/null
+++ b/internal/router/api-v1-general-page.go
@@ -0,0 +1,59 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page", "views/layouts/general-page.html"))
+}
+
+func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ path := c.Path()
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ queryString := string(c.Request().URI().QueryString())
+ cacheKey := fmt.Sprintf("general-page.%s?%s", lang, queryString)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ content, err := tm.Render("general-page", fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": queryString,
+ })
+ 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")
+ }
+
+ go PCache.Set(cacheKey, content, int64(len(content)))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go
new file mode 100644
index 0000000..323c457
--- /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() {
+ assert(0, 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/api-v1-tz.go b/internal/router/api-v1-tz.go
new file mode 100644
index 0000000..4e4e462
--- /dev/null
+++ b/internal/router/api-v1-tz.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Api_V1_TZ() func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ timestampString := c.Query("timestamp")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ var format string
+ var timestamp time.Time
+ for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
+ if timestamp, err = time.Parse(format, timestampString); err == nil {
+ break
+ }
+ }
+
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format")
+ }
+
+ return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format))
+ }
+}
diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go
deleted file mode 100644
index a859d10..0000000
--- a/internal/router/basic-handler.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package router
-
-import (
- "time"
-
- "github.com/gofiber/fiber/v2"
-)
-
-type BasicHandler struct{}
-
-var _ Route = &BasicHandler{}
-
-func (r *BasicHandler) Filter() (method string, path string) {
- panic("handler did not implement Filter method")
-}
-
-func (r *BasicHandler) IsTemplated() bool {
- return false
-}
-
-func (r *BasicHandler) ToCache() CacheSetting {
- return Disabled
-}
-
-func (r *BasicHandler) CacheDuration() time.Duration {
- return 5 * time.Minute
-}
-
-func (r *BasicHandler) ToValidateLang() LangSetting {
- return NotRequired
-}
-
-func (r *BasicHandler) TemplatesToInject() []string {
- return []string{}
-}
-
-func (r *BasicHandler) Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- if !r.IsTemplated() {
- panic("handler did not implement Render method (while being non-templated)")
- }
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
index 7c28b9e..d607cdd 100644
--- a/internal/router/client-cache.go
+++ b/internal/router/client-cache.go
@@ -24,17 +24,17 @@ type ClientCache struct {
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)
}
- slog.Debug("began db transaction", slog.String("method", "NewClientCache"))
rows, err := tx.Query("select * from blog_likes;")
if err != nil {
tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err)
}
@@ -62,7 +62,6 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
rows, err = tx.Query("select * from blog_views;")
if err != nil {
tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err)
}
@@ -82,11 +81,8 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
}
if err = tx.Commit(); err != nil {
- tx.Rollback()
- slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return nil, fmt.Errorf("fail to commit transaction in db: %w", err)
}
- slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return &ClientCache{
hashMap: make(map[string]string),
@@ -103,28 +99,18 @@ func (c *ClientCache) Close() error {
if err != nil {
return fmt.Errorf("fail to init transaction with db to dump cache: %w", err)
}
- slog.Debug("began db transaction in ClientCache.Close")
if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil {
tx.Rollback()
- slog.Debug("ended db transaction in ClientCache.Close")
return fmt.Errorf("fail to save blog_likes: %s", err)
}
if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil {
tx.Rollback()
- slog.Debug("ended db transaction in ClientCache.Close")
return fmt.Errorf("fail to save blog_views: %s", err)
}
- if err = tx.Commit(); err != nil {
- tx.Rollback()
- slog.Debug("ended db transaction in ClientCache.Close")
- return fmt.Errorf("fail to commit all the changes related to cache: %s", err)
- }
-
- slog.Debug("ended db transaction in ClientCache.Close")
- return nil
+ return tx.Commit()
}
func (c *ClientCache) GetHash(id string) string {
diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go
deleted file mode 100644
index a0316e2..0000000
--- a/internal/router/handlers/api-v1-blog-search.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "fmt"
- "log/slog"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type BlogSearchHandler struct {
- router.BasicHandler
- getTagsQuery *regexp.Regexp
-}
-
-func init() {
- router.Routes = append(router.Routes, &BlogSearchHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
-}
-
-func (r *BlogSearchHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/blog-search"
-}
-
-func (r *BlogSearchHandler) IsTemplated() bool {
- return false
-}
-
-func (r *BlogSearchHandler) TemplatesToInject() []string {
- return []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}
-}
-
-func (r *BlogSearchHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *BlogSearchHandler) ToValidateLang() router.LangSetting {
- return router.InForm
-}
-
-func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- sort := c.Query("sort")
- tz := c.Query("tz")
-
- loc, err := time.LoadLocation(tz)
- if err != nil {
- loc = time.UTC
- slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
- }
-
- cacheKey := "blog-search." + lang + ".pages-list"
-
- var pages []*b2.BlogPage
- if pagesBytes, ok := supplements.PageCache.Get(cacheKey); pagesBytes != nil || ok {
- json.Unmarshal(pagesBytes, &pages)
- } else {
- pages, err = supplements.B2Client.Scan(lang + "/")
- if err != nil {
- return fiber.StatusInternalServerError, fmt.Errorf("failed to scan pages for '%s' lang: %w", lang, err)
- }
- pagesBytes, _ := json.Marshal(pages)
- supplements.PageCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), r.CacheDuration())
- }
-
- encodedQuery := c.Request().URI().QueryString()
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1)
-
- tags := make([]string, 0, len(matches))
- for _, match := range matches {
- tags = append(tags, string(match[1]))
- }
-
- pageMeta := make([]fiber.Map, 0, len(pages))
- for _, page := range pages {
- for _, tag := range page.Metadata.Tags {
- if len(tags) == 0 || slices.Contains(tags, tag) {
- pageMeta = append(pageMeta, fiber.Map{
- "Link": page.Link,
- "ArticleLink": "/" + lang + "/blog/" + page.FileName,
- "Title": page.Metadata.Title,
- "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
- "ActionDate": page.Metadata.ActionDate,
- "ShortDescription": page.Metadata.ShortDescription,
- "Thumbnail": page.Metadata.Thumbnail,
- "Tags": page.Metadata.Tags,
- "LikeCount": supplements.ClientCache.GetLikeCount(page.FileName),
- "ViewCount": supplements.ClientCache.GetViewCount(page.FileName),
- })
- break
- }
- }
- }
-
- slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
- switch sort {
- case "titleAsc":
- return strings.Compare(a["Title"].(string), b["Title"].(string))
- case "titleDesc":
- return strings.Compare(b["Title"].(string), a["Title"].(string))
- case "actionDateAsc":
- return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
- case "actionDateDesc":
- return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
- case "publicationDateAsc":
- publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
- publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
- return publishedTimeA.Compare(publishedTimeB)
- case "publicationDateDesc":
- publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
- publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
- return publishedTimeB.Compare(publishedTimeA)
- }
- return 0
- })
-
- templateMap["BlogPages"] = pageMeta
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-email-is-in-verification.go b/internal/router/handlers/api-v1-email-is-in-verification.go
deleted file mode 100644
index 4f05f48..0000000
--- a/internal/router/handlers/api-v1-email-is-in-verification.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "html/template"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type OngoingVerificationHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &OngoingVerificationHandler{})
-}
-
-func (r *OngoingVerificationHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/email/is-in-verification"
-}
-
-func (r *OngoingVerificationHandler) IsTemplated() bool {
- return false
-}
-
-func (r *OngoingVerificationHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *OngoingVerificationHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *OngoingVerificationHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *OngoingVerificationHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- isAllowed, whenAllowed, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(c.IP())
-
- sterileDataset := make(map[string]any)
- sterileDataset["code-expiry-time"] = template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli()))
-
- templateMap["StatusId"] = "email-message"
- templateMap["DataAttributes"] = sterileDataset
-
- if !isAllowed {
- sterileDataset["striked-end-time"] = template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli()))
- templateMap["Status"] = "Neutral"
- templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
- } else {
- templateMap["Status"] = "OK"
- templateMap["Message"] = ""
- }
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-email-send-verification-code.go b/internal/router/handlers/api-v1-email-send-verification-code.go
deleted file mode 100644
index 20c5fb6..0000000
--- a/internal/router/handlers/api-v1-email-send-verification-code.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "html/template"
- "log/slog"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type SendVerificationCodeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &SendVerificationCodeHandler{})
-}
-
-func (r *SendVerificationCodeHandler) Filter() (method string, path string) {
- return "POST", "/api/v1/email/send-verification-code"
-}
-
-func (r *SendVerificationCodeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *SendVerificationCodeHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *SendVerificationCodeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *SendVerificationCodeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *SendVerificationCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- id := c.IP()
- templateMap["StatusId"] = "email-message"
-
- email := c.FormValue("email")
- if email == "" {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailEmpty
- return fiber.StatusUnprocessableEntity, nil
- }
-
- isTaken, err := supplements.Mailer.MailIsTaken(email)
- if err != nil {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
- slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if isTaken {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailTaken
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if isAllowed, whenAllowed, _ := supplements.Mailer.IsAllowedToRetryVerification(id); !isAllowed {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
- templateMap["DataAttributes"] = map[string]any{
- "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli())),
- }
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if previousEmail, _, _ := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(id)); previousEmail == email {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailAlreadyValidated
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if err = supplements.Mailer.SendVerificationCode(id, email, lang); err != nil {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
- slog.Error("failed to send a verification code", slog.String("error", err.Error()))
- return fiber.StatusUnprocessableEntity, nil
- }
-
- _, endTime, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(id)
- templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSent
- templateMap["DataAttributes"] = map[string]any{
- "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", endTime.UnixMilli())),
- "code-expiry-time": template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli())),
- }
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-email-verify.go b/internal/router/handlers/api-v1-email-verify.go
deleted file mode 100644
index c9ec969..0000000
--- a/internal/router/handlers/api-v1-email-verify.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package handlers
-
-import (
- "html/template"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type VerifyCodeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &VerifyCodeHandler{})
-}
-
-func (r *VerifyCodeHandler) Filter() (method string, path string) {
- return "POST", "/api/v1/email/verify"
-}
-
-func (r *VerifyCodeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *VerifyCodeHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *VerifyCodeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *VerifyCodeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *VerifyCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- verificationCode := c.FormValue("email_code")
- templateMap["StatusId"] = "verification-message"
-
- if verificationCode == "" {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationEmpty
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if err = supplements.Mailer.Verify(verificationCode, lang); err != nil {
- slog.Warn("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationFailed
- return fiber.StatusUnprocessableEntity, nil
- }
-
- templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationSuccess
- templateMap["DataAttributes"] = map[string]any{
- "hide-verification-panel": template.HTMLAttr("data-code-expiry-time=\"true\""),
- }
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-like-get.go b/internal/router/handlers/api-v1-like-get.go
deleted file mode 100644
index e9c176e..0000000
--- a/internal/router/handlers/api-v1-like-get.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type GetLikeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &GetLikeHandler{})
-}
-
-func (r *GetLikeHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/like"
-}
-
-func (r *GetLikeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *GetLikeHandler) TemplatesToInject() []string {
- return []string{"views/partials/blog-page-like-button.html"}
-}
-
-func (r *GetLikeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *GetLikeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- path, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
- }
-
- if len(pathParts) != 3 && pathParts[1] != "blog" {
- return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
- }
-
- page := pathParts[2]
- pageLink := lang + "/" + page + ".md"
- if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
- return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
- }
-
- ip := c.IP()
- likeStatus := supplements.ClientCache.GetLikeStatus(ip, page)
-
- slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
- templateMap["Liked"] = likeStatus
- templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-like-put.go b/internal/router/handlers/api-v1-like-put.go
deleted file mode 100644
index ea4ef4b..0000000
--- a/internal/router/handlers/api-v1-like-put.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "strconv"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type PutLikeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &PutLikeHandler{})
-}
-
-func (r *PutLikeHandler) Filter() (method string, path string) {
- return "PUT", "/api/v1/like"
-}
-
-func (r *PutLikeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *PutLikeHandler) TemplatesToInject() []string {
- return []string{"views/partials/blog-page-like-button.html"}
-}
-
-func (r *PutLikeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *PutLikeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *PutLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- path, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
- }
-
- if len(pathParts) != 3 && pathParts[1] != "blog" {
- return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
- }
-
- page := pathParts[2]
- pageLink := lang + "/" + page + ".md"
- if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
- return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
- }
-
- newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("invalid like value '%s'", c.FormValue("like"))
- }
-
- ip := c.IP()
- if newLikeStatus {
- supplements.ClientCache.LikeOn(ip, page)
- } else {
- supplements.ClientCache.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)))
- templateMap["Liked"] = newLikeStatus
- templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
- templateMap["StatusId"] = "email-message"
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-subs-put.go b/internal/router/handlers/api-v1-subs-put.go
deleted file mode 100644
index 144abe5..0000000
--- a/internal/router/handlers/api-v1-subs-put.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package handlers
-
-import (
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type PutSubsHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &PutSubsHandler{})
-}
-
-func (r *PutSubsHandler) Filter() (method string, path string) {
- return "PUT", "/api/v1/subs"
-}
-
-func (r *PutSubsHandler) IsTemplated() bool {
- return false
-}
-
-func (r *PutSubsHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *PutSubsHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *PutSubsHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *PutSubsHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["StatusId"] = "subs-message"
-
- subscriptionType := c.FormValue("tags")
- var subscriptionTypeEnum mailer.SubscriptionType
- switch subscriptionType {
- case "all":
- subscriptionTypeEnum = mailer.All
- case "none":
- subscriptionTypeEnum = mailer.None
- case "specific":
- subscriptionTypeEnum = mailer.Specific
- default:
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribeInvalidType
- return fiber.StatusUnprocessableEntity, nil
- }
-
- specificTags := c.FormValue("tags_picked")
- if err = supplements.Mailer.Subscribe(supplements.Mailer.GetHash(c.IP()), subscriptionTypeEnum, specificTags); err != nil {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.FailedToSubscribe
- return fiber.StatusUnprocessableEntity, nil
- }
-
- templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribedSuccessfully
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-tz-get.go b/internal/router/handlers/api-v1-tz-get.go
deleted file mode 100644
index 8b08113..0000000
--- a/internal/router/handlers/api-v1-tz-get.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type GetTimezoneHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &GetTimezoneHandler{})
-}
-
-func (r *GetTimezoneHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/tz"
-}
-
-func (r *GetTimezoneHandler) IsTemplated() bool {
- return false
-}
-
-func (r *GetTimezoneHandler) TemplatesToInject() []string {
- return []string{}
-}
-
-func (r *GetTimezoneHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *GetTimezoneHandler) ToValidateLang() router.LangSetting {
- return router.NotRequired
-}
-
-func (r *GetTimezoneHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- timestampString := c.Query("timestamp")
- tz := c.Query("tz")
-
- loc, err := time.LoadLocation(tz)
- if err != nil {
- loc = time.UTC
- slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
- }
-
- var format string
- var timestamp time.Time
- for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
- if timestamp, err = time.Parse(format, timestampString); err == nil {
- break
- }
- }
-
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("invalid timestamp format for '%s'", timestampString)
- }
-
- templateMap["Output"] = []byte(timestamp.In(loc).Format("2006-01-02 15:04:05 -07:00"))
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go
deleted file mode 100644
index 0f4f564..0000000
--- a/internal/router/handlers/lang-blog-title.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "fmt"
- "html/template"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/frontmatter"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
- "github.com/yuin/goldmark"
-)
-
-func init() {
- router.Routes = append(router.Routes, &BlogPageHandler{})
-}
-
-type BlogPageHandler struct {
- router.BasicHandler
-}
-
-func (r *BlogPageHandler) Filter() (method string, path string) {
- return "GET", "/:lang/blog/:title"
-}
-
-func (r *BlogPageHandler) IsTemplated() bool {
- return true
-}
-
-func (r *BlogPageHandler) TemplatesToInject() []string {
- return []string{"views/pages/blog-page.html"}
-}
-
-func (r *BlogPageHandler) ToCache() router.CacheSetting {
- return router.ByUrlOnly
-}
-
-func (r *BlogPageHandler) CacheDuration() time.Duration {
- return 15 * time.Minute
-}
-
-func (r *BlogPageHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- _, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
- }
-
- metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.B2Client, lang+"/"+pathParts[2])
- if err != nil {
- return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", pathParts[2], err)
- }
-
- geolocationParts := strings.Split(metadata.Geolocation, " ")
- var x, y, areaError string
- if len(geolocationParts) >= 2 {
- x = geolocationParts[0]
- y = geolocationParts[1]
- }
- if len(geolocationParts) >= 3 {
- areaError = geolocationParts[2]
- }
-
- templateMap["MapLocationX"] = x
- templateMap["MapLocationY"] = y
- templateMap["MapLocationAreaMeters"] = areaError
- templateMap["Title"] = metadata.Title
- templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown)
- templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
- templateMap["ActionDate"] = metadata.ActionDate
- templateMap["ShortDescription"] = metadata.ShortDescription
-
- go supplements.ClientCache.View(c.IP(), pathParts[2])
-
- return fiber.StatusOK, nil
-}
-
-func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- path, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
- }
-
- metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
- if err != nil {
- return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err)
- }
-
- templateMap["Title"] = metadata.Title
-
- return fiber.StatusOK, nil
-}
-
-func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
- metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
- if err != nil {
- return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
- }
-
- var buf bytes.Buffer
- if err := md.Convert(markdown, &buf); err != nil {
- return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
- }
-
- return metadata, buf.String(), nil
-}
diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go
deleted file mode 100644
index e4c20c6..0000000
--- a/internal/router/handlers/lang-blog.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &CatalogueHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
-}
-
-type CatalogueHandler struct {
- router.BasicHandler
- getTagsQuery *regexp.Regexp
-}
-
-func (r *CatalogueHandler) Filter() (method string, path string) {
- return "GET", "/:lang/blog"
-}
-
-func (r *CatalogueHandler) IsTemplated() bool {
- return true
-}
-
-func (r *CatalogueHandler) TemplatesToInject() []string {
- return []string{"views/pages/blog-catalogue.html"}
-}
-
-func (r *CatalogueHandler) ToCache() router.CacheSetting {
- return router.ByUrlAndQuery
-}
-
-func (r *CatalogueHandler) CacheDuration() time.Duration {
- return 5 * time.Minute
-}
-
-func (r *CatalogueHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- querySort := c.Query("sort")
- if querySort == "" {
- querySort = "publicationDateDesc"
- }
-
- encodedQuery := c.Request().URI().QueryString()
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1)
-
- queryTags := make([]string, 0, len(matches))
- for _, match := range matches {
- queryTags = append(queryTags, string(match[1]))
- }
-
- tagsArray, err := getTags(supplements.B2Client, lang)
- if err != nil {
- return fiber.StatusInternalServerError, fmt.Errorf("failed to get the available tags")
- }
-
- templateMap["Tags"] = tagsArray
- templateMap["QuerySort"] = querySort
- templateMap["QueryTags"] = strings.Join(queryTags, ",")
- templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
-
- return fiber.StatusOK, nil
-}
-
-func (r *CatalogueHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
- return fiber.StatusOK, nil
-}
-
-type Tag struct {
- Name string `json:"Name" yaml:"name"`
- Count int `json:"Count" yaml:"count"`
-}
-
-func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) {
- pages, err := b2Client.Scan(lang + "/")
- if err != nil {
- slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
- return nil, fmt.Errorf("failed to scan pages via b2: %w", err)
- }
-
- tagsMap := make(map[string]int)
- for _, page := range pages {
- 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("lang", lang))
-
- tagsArray := make([]Tag, 0, len(tagsMap))
- for tag, count := range tagsMap {
- tagsArray = append(tagsArray, Tag{tag, count})
- }
- slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
- return strings.Compare(a.Name, b.Name)
- })
-
- return tagsArray, nil
-}
diff --git a/internal/router/handlers/lang-map.go b/internal/router/handlers/lang-map.go
deleted file mode 100644
index dbf1437..0000000
--- a/internal/router/handlers/lang-map.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type MapHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &MapHandler{})
-}
-
-func (r *MapHandler) Filter() (method string, path string) {
- return "GET", "/:lang/map"
-}
-
-func (r *MapHandler) IsTemplated() bool {
- return false
-}
-
-func (r *MapHandler) TemplatesToInject() []string {
- return []string{"views/pages/global-map.html"}
-}
-
-func (r *MapHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *MapHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- pages, err := supplements.B2Client.Scan(lang + "/")
- status := fiber.StatusOK
- if err != nil {
- slog.Error("received an error while scanning b2 pages",
- slog.String("error", err.Error()),
- slog.String("lang", lang),
- )
- status = fiber.StatusPartialContent
- pages = []*b2.BlogPage{}
- }
-
- type MapMarker struct {
- Title string `json:"Title"`
- PageLink string `json:"PageLink"`
- Lat float64 `json:"Lat"`
- Long float64 `json:"Long"`
- AccuracyMeters int64 `json:"AccuracyMeters"`
- Thumbnail string `json:"Thumbnail"`
- }
-
- mapMarkers := make([]*MapMarker, 0, len(pages))
- for _, page := range pages {
- geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
- if len(geolocationParts) < 2 {
- continue
- }
-
- var x, y float64
- var areaError int64
- if len(geolocationParts) >= 2 {
- x, _ = strconv.ParseFloat(geolocationParts[0], 64)
- y, _ = strconv.ParseFloat(geolocationParts[1], 64)
- }
- if len(geolocationParts) >= 3 {
- areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
- }
-
- mapMarkers = append(mapMarkers, &MapMarker{
- Title: page.Metadata.Title,
- PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
- Lat: x,
- Long: y,
- AccuracyMeters: areaError,
- Thumbnail: page.Metadata.Thumbnail,
- })
- }
-
- templateMap["MapMarkers"] = mapMarkers
- templateMap["MapLocationLat"] = 45.4507
- templateMap["MapLocationLong"] = 68.8319
-
- return status, nil
-}
diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go
deleted file mode 100644
index 4fbd244..0000000
--- a/internal/router/handlers/lang-user-unsubscribe.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package handlers
-
-import (
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type UnsubscribeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &UnsubscribeHandler{})
-}
-
-func (r *UnsubscribeHandler) Filter() (method string, path string) {
- return "GET", "/:lang/user/unsubscribe"
-}
-
-func (r *UnsubscribeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *UnsubscribeHandler) TemplatesToInject() []string {
- return []string{"views/pages/unsubscribe-page.html"}
-}
-
-func (r *UnsubscribeHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *UnsubscribeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- var statusEmoji, statusText, statusColor string
- var status int
-
- unsubscribeCode := c.FormValue("code")
- if unsubscribeCode == "" {
- statusColor = "0, 0, 255"
- statusEmoji = "(╭ರ_•́)"
- statusText = supplements.Localization[lang].UnsubscribePage.UnsetCode
- status = fiber.ErrBadRequest.Code
- } else if clientError, serverError := supplements.Mailer.Unsubscribe(unsubscribeCode); clientError != nil {
- slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error()))
- statusColor = "255, 0, 0"
- statusEmoji = "(͠≖~≖ ͡ )"
- statusText = supplements.Localization[lang].UnsubscribePage.InvalidCode
- status = fiber.ErrBadRequest.Code
- } else if serverError != nil {
- slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error()))
- statusColor = "255, 128, 0"
- statusEmoji = "( ˶°ㅁ°) !!"
- statusText = supplements.Localization[lang].UnsubscribePage.OnServerError
- status = fiber.ErrInternalServerError.Code
- } else {
- statusColor = "0, 255, 0"
- statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
- statusText = supplements.Localization[lang].UnsubscribePage.Success
- status = fiber.StatusOK
- }
-
- templateMap["StatusColor"] = statusColor
- templateMap["StatusEmoji"] = statusEmoji
- templateMap["StatusText"] = statusText
-
- return status, nil
-}
diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go
deleted file mode 100644
index db566fe..0000000
--- a/internal/router/handlers/lang-user.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &UserHandler{})
-}
-
-type UserHandler struct {
- router.BasicHandler
-}
-
-func (r *UserHandler) Filter() (method string, path string) {
- return "GET", "/:lang/user"
-}
-
-func (r *UserHandler) IsTemplated() bool {
- return true
-}
-
-func (r *UserHandler) TemplatesToInject() []string {
- return []string{"views/pages/user-page.html"}
-}
-
-func (r *UserHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *UserHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
-
- email, _, err := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(c.IP()))
- if err != nil {
- slog.Error("get info from mailer about a client", slog.String("error", err.Error()))
- }
-
- tagsArray, err := getTags(supplements.B2Client, lang)
- if err != nil {
- return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the available tags")
- }
-
- subscriptionType, tags, err := supplements.Mailer.GetSubscriptions(c.IP())
- if err != nil {
- return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the user subscriptions")
- }
-
- switch subscriptionType {
- case mailer.None:
- templateMap["TagsPicked"] = "none"
- case mailer.All:
- templateMap["TagsPicked"] = "all"
- case mailer.Specific:
- templateMap["TagsPicked"] = "specific"
- }
-
- templateMap["TagsPickedList"] = tags
-
- templateMap["Email"] = email
- templateMap["EmailCode"] = c.Query("email_code")
- templateMap["ExistingTags"] = tagsArray
- return fiber.StatusOK, nil
-}
-
-func (r *UserHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go
deleted file mode 100644
index fc4deb6..0000000
--- a/internal/router/handlers/lang.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "math/rand/v2"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &HomeHandler{})
-}
-
-type HomeHandler struct {
- router.BasicHandler
-}
-
-func (r *HomeHandler) Filter() (method string, path string) {
- return "GET", "/:lang<len(2)>"
-}
-
-func (r *HomeHandler) IsTemplated() bool {
- return true
-}
-
-func (r *HomeHandler) TemplatesToInject() []string {
- return []string{"views/pages/home-page.html"}
-}
-
-func (r *HomeHandler) ToCache() router.CacheSetting {
- return router.ByUrlOnly
-}
-
-func (r *HomeHandler) CacheDuration() time.Duration {
- return 10 * time.Minute
-}
-
-func (r *HomeHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].HomePage.Header
- templateMap["FilledHeartCount"] = uint(40)
- templateMap["OutlineHeartCount"] = uint(40)
- templateMap["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
- templateMap["FunFacts"] = supplements.FactGiver.Give(lang)
- return fiber.StatusOK, nil
-}
-
-func (r *HomeHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].HomePage.Header
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go
deleted file mode 100644
index c8c4cd4..0000000
--- a/internal/router/handlers/root.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package handlers
-
-import (
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &RootHandler{})
-}
-
-type RootHandler struct {
- router.BasicHandler
-}
-
-func (r *RootHandler) Filter() (method string, path string) {
- return "GET", "/"
-}
-
-func (r *RootHandler) IsTemplated() bool {
- return true
-}
-
-func (r *RootHandler) TemplatesToInject() []string {
- return []string{"views/pages/language-pick.html"}
-}
-
-func (r *RootHandler) ToCache() router.CacheSetting {
- return router.ByUrlOnly
-}
-
-func (r *RootHandler) CacheDuration() time.Duration {
- return 24 * time.Hour
-}
-
-func (r *RootHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = "Choose Your Language"
- templateMap["AvailableLanguages"] = supplements.AvailableLanguages
- return fiber.StatusOK, nil
-}
-
-func (r *RootHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = "Choose Your Language"
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go
new file mode 100644
index 0000000..619b05b
--- /dev/null
+++ b/internal/router/lang-map.go
@@ -0,0 +1,88 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("global-map", "views/pages/global-map.html"))
+}
+
+func Lang_Map(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ lang := c.Params("lang")
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
+
+ langIsAvailable:
+ pages, err := b2Client.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ status = fiber.StatusPartialContent
+ pages = []*b2.BlogPage{}
+ }
+
+ type MapMarker struct {
+ Title string `json:"Title"`
+ PageLink string `json:"PageLink"`
+ Lat float64 `json:"Lat"`
+ Long float64 `json:"Long"`
+ AccuracyMeters int64 `json:"AccuracyMeters"`
+ Thumbnail string `json:"Thumbnail"`
+ }
+
+ mapMarkers := make([]*MapMarker, 0, len(pages))
+ for _, page := range pages {
+ geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
+ if len(geolocationParts) < 2 {
+ continue
+ }
+
+ var x, y float64
+ var areaError int64
+ if len(geolocationParts) >= 2 {
+ x, _ = strconv.ParseFloat(geolocationParts[0], 64)
+ y, _ = strconv.ParseFloat(geolocationParts[1], 64)
+ }
+ if len(geolocationParts) >= 3 {
+ areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
+ }
+
+ mapMarkers = append(mapMarkers, &MapMarker{
+ Title: page.Metadata.Title,
+ PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
+ Lat: x,
+ Long: y,
+ AccuracyMeters: areaError,
+ Thumbnail: page.Metadata.Thumbnail,
+ })
+ }
+
+ content, err := tm.Render("global-map", fiber.Map{
+ "Lang": lang,
+ "L": l[lang],
+ "MapLocationLat": 45.4507,
+ "MapLocationLong": 68.8319,
+ "MapMarkers": mapMarkers,
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
+ }
+
+ return c.Type("html").Status(status).Send(content)
+ }
+}
diff --git a/internal/router/page-cache.go b/internal/router/page-cache.go
new file mode 100644
index 0000000..01e4792
--- /dev/null
+++ b/internal/router/page-cache.go
@@ -0,0 +1,5 @@
+package router
+
+import "github.com/dgraph-io/ristretto/v2"
+
+var PCache *ristretto.Cache[string, []byte]
diff --git a/internal/router/path-matcher.go b/internal/router/path-matcher.go
deleted file mode 100644
index c8aef08..0000000
--- a/internal/router/path-matcher.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package router
-
-import (
- "github.com/gofiber/fiber/v2"
- "github.com/valyala/fasthttp"
-)
-
-type PathMatcher struct {
- app *fiber.App
-}
-
-func NewPathMatcher() *PathMatcher {
- app := fiber.New(fiber.Config{
- DisableStartupMessage: true,
- })
- return &PathMatcher{app: app}
-}
-
-func (pm *PathMatcher) AddRoute(method, pattern string) {
- pm.app.Add(method, pattern, func(c *fiber.Ctx) error {
- c.Locals("pattern", pattern)
- return nil
- })
-}
-
-func (pm *PathMatcher) MatchPath(method, path string) (pattern string, params map[string]string, matched bool) {
- fctx := &fasthttp.RequestCtx{}
- fctx.Request.Header.SetMethod(method)
- fctx.Request.SetRequestURI(path)
-
- ctx := pm.app.AcquireCtx(fctx)
- defer pm.app.ReleaseCtx(ctx)
-
- pm.app.Handler()(fctx)
-
- if ctx.Route() == nil {
- return "", nil, false
- }
-
- pattern = ctx.Locals("pattern").(string)
-
- params = make(map[string]string)
- for _, paramName := range ctx.Route().Params {
- params[paramName] = ctx.Params(paramName)
- }
-
- return pattern, params, true
-}
diff --git a/internal/router/router.go b/internal/router/router.go
deleted file mode 100644
index c0556f0..0000000
--- a/internal/router/router.go
+++ /dev/null
@@ -1,564 +0,0 @@
-package router
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "log/slog"
- "net/url"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
- "github.com/SayaAndy/saya-today-web/internal/factgiver"
- "github.com/SayaAndy/saya-today-web/internal/glightbox"
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/internal/tailwind"
- "github.com/SayaAndy/saya-today-web/internal/templatemanager"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/dgraph-io/ristretto/v2"
- "github.com/gofiber/fiber/v2"
- "github.com/gofiber/fiber/v2/middleware/cors"
- "github.com/golang-migrate/migrate/v4"
- "github.com/golang-migrate/migrate/v4/database/sqlite3"
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/renderer"
- "github.com/yuin/goldmark/renderer/html"
- "github.com/yuin/goldmark/util"
-)
-
-type CacheSetting int
-
-const (
- Disabled CacheSetting = iota
- ByUrlOnly
- ByUrlAndQuery
-)
-
-type LangSetting int
-
-const (
- NotRequired LangSetting = iota
- InPath
- InForm
- InReferer
-)
-
-var (
- Routes = make([]Route, 0)
-)
-
-type Route interface {
- Filter() (method string, path string)
- IsTemplated() bool
- ToCache() CacheSetting
- CacheDuration() time.Duration
- ToValidateLang() LangSetting
- TemplatesToInject() []string
- Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
-}
-
-type Supplements struct {
- DB *sql.DB
- B2Client *b2.B2Client
- Localization map[string]*locale.LocaleConfig
- AvailableLanguages []config.AvailableLanguageConfig
- ClientCache *ClientCache
- PageCache *ristretto.Cache[string, []byte]
- FactGiver *factgiver.FactGiver
- Mailer *mailer.Mailer
- BlogTrigger *blogtrigger.BlogTriggerScheduler
- TemplateManager *templatemanager.TemplateManager
- MarkdownRenderer goldmark.Markdown
-}
-
-type Router struct {
- supplements *Supplements
- app *fiber.App
- templatedRoutes map[string]map[string]Route
- templatedPathMatcher *PathMatcher
-}
-
-func NewRouter(cfg *config.Config) (*Router, error) {
- supplements := &Supplements{
- AvailableLanguages: cfg.AvailableLanguages,
- }
-
- var err error
-
- supplements.DB, err = sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize db: %w", err)
- }
-
- driver, err := sqlite3.WithInstance(supplements.DB, &sqlite3.Config{})
- if err != nil {
- return nil, fmt.Errorf("fail to initialize driver for migrating db: %w", err)
- }
-
- m, err := migrate.NewWithDatabaseInstance(
- "file://migrations",
- cfg.Auth.Db.Type, driver)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize migration client: %w", err)
- }
-
- if err = m.Up(); err != nil && err == errors.New("no change") {
- return nil, fmt.Errorf("fail to apply migrations: %w", err)
- }
- slog.Debug("successfully applied migrations")
-
- supplements.B2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize b2 client: %w", err)
- }
-
- supplements.Localization = make(map[string]*locale.LocaleConfig, len(cfg.AvailableLanguages))
- for _, lang := range cfg.AvailableLanguages {
- localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize a locale: %w", err)
- }
- supplements.Localization[lang.Name] = localeCfg
- }
-
- supplements.MarkdownRenderer = goldmark.New(
- goldmark.WithExtensions(
- glightbox.NewGLightboxExtension(),
- tailwind.NewTailwindExtension(),
- ),
- goldmark.WithParserOptions(
- parser.WithAutoHeadingID(),
- parser.WithAttribute(),
- ),
- goldmark.WithRenderer(
- renderer.NewRenderer(
- renderer.WithNodeRenderers(
- util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50),
- util.Prioritized(html.NewRenderer(html.WithXHTML()), 100),
- ),
- ),
- ),
- )
-
- supplements.ClientCache, err = NewClientCache(supplements.DB, []byte(cfg.Auth.Salt))
- if err != nil {
- return nil, fmt.Errorf("fail to initialize client cache: %w", err)
- }
-
- supplements.PageCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
- NumCounters: 1e6, // 1,000,000
- MaxCost: 1 << 29, // 512 MB
- BufferItems: 64, // number of keys per Get buffer.
- })
- if err != nil {
- return nil, fmt.Errorf("fail to initialize page cache: %w", err)
- }
-
- supplements.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, supplements.AvailableLanguages)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize fact giver: %w", err)
- }
-
- supplements.Mailer, err = mailer.NewMailer(supplements.DB, cfg.Mail.ClientHost, cfg.Mail.MailHost,
- cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), supplements.Localization)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize mailer: %w", err)
- }
-
- supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.B2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost,
- func(bp []*b2.BlogPage) error {
- for _, post := range bp {
- if err := supplements.Mailer.NewPost(post); err != nil {
- return err
- }
- }
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("fail to initialize blog trigger: %w", err)
- }
-
- supplements.TemplateManager, err = templatemanager.NewTemplateManager()
- if err != nil {
- return nil, fmt.Errorf("fail to initialize template manager: %w", err)
- }
-
- enablePrintRoutes := false
- if cfg.LogLevel <= slog.LevelDebug {
- enablePrintRoutes = true
- }
-
- app := fiber.New(fiber.Config{
- EnablePrintRoutes: enablePrintRoutes,
- ProxyHeader: "X-Forwarded-For",
- })
-
- app.Use(cors.New(cors.Config{
- AllowOrigins: "https://f003.backblazeb2.com",
- AllowMethods: "GET,POST,OPTIONS",
- AllowHeaders: "Origin, Content-Type, Accept",
- }))
-
- templatedRoutes := make(map[string]map[string]Route)
- templatedPathMatcher := NewPathMatcher()
-
- return &Router{supplements, app, templatedRoutes, templatedPathMatcher}, nil
-}
-
-func (r *Router) InitRoutes() (err error) {
- r.supplements.TemplateManager.Add("general-page", "views/layouts/general-page.html")
- for _, route := range Routes {
- method, match := route.Filter()
-
- if err = r.supplements.TemplateManager.Add(method+" "+match, route.TemplatesToInject()...); err != nil {
- return fmt.Errorf("failed to add '%s %s' route into template manager: %w", method, match, err)
- }
-
- if route.IsTemplated() {
- if _, ok := r.templatedRoutes[method]; !ok {
- r.templatedRoutes[method] = make(map[string]Route)
- }
- r.templatedRoutes[method][match] = route
- r.templatedPathMatcher.AddRoute(method, match)
-
- currentRoute := route
- r.app.Add(method, match, func(c *fiber.Ctx) error {
- lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
- if err != nil {
- return err
- }
- err = r.generalPage(c, currentRoute, lang)
- return err
- })
- } else {
- currentRoute := route
- r.app.Add(method, match, func(c *fiber.Ctx) error {
- lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
- if err != nil {
- return err
- }
-
- var cacheKey string
- trimmedPath := strings.Trim(c.Path(), "/")
- queryString := c.Request().URI().QueryString()
-
- switch currentRoute.ToCache() {
- case ByUrlOnly:
- cacheKey = fmt.Sprintf("%s.full-page.%s", method, trimmedPath)
- case ByUrlAndQuery:
- cacheKey = fmt.Sprintf("%s.full-page.%s.%s", method, trimmedPath, queryString)
- }
-
- defaultMap := fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "Path": trimmedPath,
- "QueryString": queryString,
- }
-
- statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap)
- method := c.Method()
- _, match := currentRoute.Filter()
- if err != nil {
- slog.Error("failed to finish rendering a page",
- slog.Int("status_code", statusCode),
- slog.String("method", method),
- slog.String("path", c.Path()),
- slog.String("match", match),
- slog.String("query", string(queryString)),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(statusCode).SendString(err.Error())
- }
-
- content, err := r.supplements.TemplateManager.Render(method+" "+match, defaultMap)
- if err != nil {
- slog.Error("failed to generate div",
- slog.String("method", method),
- slog.String("path", c.Path()),
- slog.String("match", match),
- slog.String("query", string(queryString)),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
- if val, ok := defaultMap["Output"]; ok && len(content) == 0 {
- content = val.([]byte)
- }
-
- if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled {
- go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
- }
-
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(statusCode).Type("html").Send(content)
- })
- }
- }
-
- segments := []string{"header", "body", "footer", "top-embeds", "bottom-embeds"}
-
- r.app.Get("/api/v1/general-page/:part", func(c *fiber.Ctx) error {
- part := c.Params("part")
- if !slices.Contains(segments, part) {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("unknown segment '%s'", part))
- }
- err := r.generalPageSegment(c, part)
- return err
- })
-
- for _, segment := range segments {
- r.supplements.TemplateManager.Add("general-page-"+segment, "views/partials/general-page-"+segment+".html")
- }
-
- r.app.Static("/", "./static")
-
- Routes = make([]Route, 0)
-
- return nil
-}
-
-func (r *Router) Listen(endpoint string) error {
- if err := r.app.Listen(endpoint); err != nil {
- return fmt.Errorf("error while running fiber server: %w", err)
- }
- return nil
-}
-
-func (r *Router) Close() (err error) {
- allErrors := make([]error, 0)
- slog.Debug("shutting down blog trigger scheduler")
- if err = r.supplements.BlogTrigger.Close(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to shutdown blog trigger scheduler: %w", err))
- }
- slog.Debug("shutting down fiber server")
- if err = r.app.Shutdown(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to shutdown fiber server: %w", err))
- }
- slog.Debug("dumping cache")
- if err = r.supplements.ClientCache.Close(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to dump cache: %w", err))
- }
- slog.Debug("closing db connection")
- if err = r.supplements.DB.Close(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to close db connection: %w", err))
- }
- slog.Debug("closing page cache")
- r.supplements.PageCache.Close()
- return errors.Join(allErrors...)
-}
-
-func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- path := c.Path()
- method := c.Method()
- trimmedPath := strings.Trim(path, "/")
- cacheKey := ""
- queryString := c.Request().URI().QueryString()
-
- switch route.ToCache() {
- case ByUrlOnly:
- cacheKey = fmt.Sprintf("%s.general-page.%s", method, trimmedPath)
- case ByUrlAndQuery:
- cacheKey = fmt.Sprintf("%s.general-page.%s.%s", method, trimmedPath, queryString)
- }
-
- if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- content, err := r.supplements.TemplateManager.Render("general-page", fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "QueryString": queryString,
- })
- 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")
- }
-
- go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
-}
-
-func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- path, pathParts, queryString, err := GetPathFromReferer(c)
- if err != nil {
- return err
- }
-
- method := c.Method()
- pattern, _, matched := r.templatedPathMatcher.MatchPath(method, path)
- if !matched {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("fail to get templated page for '%s %s': not found", method, path))
- }
- route := r.templatedRoutes[method][pattern]
-
- lang := ""
-
- switch route.ToValidateLang() {
- case InPath, InReferer:
- if len(pathParts) > 0 {
- lang = pathParts[0]
- }
- if _, err := r.getAndValidateLang(c, NotRequired, lang); err != nil {
- return err
- }
- case InForm:
- if lang, err = r.getAndValidateLang(c, InForm); err != nil {
- return err
- }
- }
-
- cacheKey := ""
- trimmedPath := strings.Trim(path, "/")
- switch route.ToCache() {
- case ByUrlOnly:
- cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath)
- case ByUrlAndQuery:
- cacheKey = fmt.Sprintf("%s.%s.%s.%s", method, part, trimmedPath, queryString)
- }
-
- if route.ToCache() != Disabled {
- if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
- }
-
- var statusCode int
- defaultMap := fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": queryString,
- }
-
- switch part {
- case "body":
- statusCode, err = route.RenderBody(c, r.supplements, lang, defaultMap)
- case "header":
- statusCode, err = route.RenderHeader(c, r.supplements, lang, defaultMap)
- case "footer":
- statusCode, err = route.RenderFooter(c, r.supplements, lang, defaultMap)
- case "top-embeds":
- statusCode, err = route.RenderTopEmbeds(c, r.supplements, lang, defaultMap)
- case "bottom-embeds":
- statusCode, err = route.RenderBottomEmbeds(c, r.supplements, lang, defaultMap)
- }
- if err != nil {
- slog.Error(err.Error(),
- slog.String("method", method),
- slog.String("path", path),
- slog.String("query", string(queryString)),
- slog.String("segment", part),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(statusCode).SendString(err.Error())
- }
-
- content, err := r.supplements.TemplateManager.Render("general-page-"+part, defaultMap, route.TemplatesToInject()...)
- if err != nil {
- slog.Error("failed to generate div",
- slog.String("method", method),
- slog.String("path", path),
- slog.String("query", string(queryString)),
- slog.String("segment", part),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- if statusCode == fiber.StatusNoContent && len(content) != 0 {
- statusCode = fiber.StatusOK
- }
-
- if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled {
- go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
- }
-
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(statusCode).Type("html").Send(content)
-}
-
-func (r *Router) getAndValidateLang(c *fiber.Ctx, langSetting LangSetting, defaultLang ...string) (string, error) {
- var lang string
- if len(defaultLang) > 0 {
- lang = defaultLang[0]
- }
-
- switch langSetting {
- case NotRequired:
- return lang, nil
-
- case InPath:
- path := c.Path()
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 && len(pathParts[0]) == 2 {
- lang = pathParts[0]
- }
-
- case InForm:
- lang = c.FormValue("lang")
-
- case InReferer:
- _, pathParts, _, err := GetPathFromReferer(c)
- if err != nil {
- return "", err
- }
-
- if len(pathParts) >= 1 {
- lang = pathParts[0]
- }
- }
-
- for _, availableLang := range r.supplements.AvailableLanguages {
- if availableLang.Name == lang {
- return lang, nil
- }
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("lang value is invalid: '%s' is not considered an available language", lang))
-}
-
-func GetPathFromReferer(c *fiber.Ctx) (path string, pathParts []string, queryString string, err error) {
- referer := c.Get("Referer")
- if referer == "" {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path = urlStruct.EscapedPath()
- pathParts = strings.Split(strings.Trim(path, "/"), "/")
- queryString = urlStruct.RawQuery
- return
-}
diff --git a/internal/router/tm.go b/internal/router/tm.go
new file mode 100644
index 0000000..e8095ee
--- /dev/null
+++ b/internal/router/tm.go
@@ -0,0 +1,18 @@
+package router
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+)
+
+var tm = assert(templatemanager.NewTemplateManager())
+
+func assert[T any](t T, err error) T {
+ if err != nil {
+ slog.Error("fail to initialize template manager", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ return t
+}
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
index 75309e4..bafdee5 100644
--- a/internal/templatemanager/templatemanager.go
+++ b/internal/templatemanager/templatemanager.go
@@ -39,18 +39,10 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager
for _, tmplStruct := range templates {
tmpl := template.New(tmplStruct.Name).Funcs(templateFuncMap)
- if len(tmplStruct.Files) == 0 {
- templateMap[tmplStruct.Name] = templateManagerRender{
- Main: "",
- Tmpl: tmpl,
- }
- continue
- }
tmpl, err := tmpl.ParseFiles(tmplStruct.Files...)
if err != nil {
return nil, err
}
-
templateMap[tmplStruct.Name] = templateManagerRender{
Main: filepath.Base(tmplStruct.Files[0]),
Tmpl: tmpl,
@@ -65,16 +57,12 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager
func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) {
tmpl, exists := tm.templates[name]
if !exists {
- return nil, fmt.Errorf("template %s not found", name)
+ return nil, fmt.Errorf("template %s is not found", name)
}
var err error
var tempTmpl *template.Template
- var mainTmpl string = tmpl.Main
if len(files) == 0 {
- if mainTmpl == "" {
- return []byte{}, nil
- }
tempTmpl = tmpl.Tmpl
} else {
tempTmpl, err = tmpl.Tmpl.Clone()
@@ -85,27 +73,20 @@ func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byt
if err != nil {
return nil, fmt.Errorf("couldn't include additional files in template rendering: %w", err)
}
- if mainTmpl == "" {
- mainTmpl = filepath.Base(files[0])
- }
}
var buf bytes.Buffer
- err = tempTmpl.ExecuteTemplate(&buf, mainTmpl, data)
+ err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data)
return buf.Bytes(), err
}
func (tm *TemplateManager) Add(name string, files ...string) error {
- tmpl := template.New(name).Funcs(templateFuncMap)
-
if len(files) == 0 {
- tm.templates[name] = templateManagerRender{
- Main: "",
- Tmpl: tmpl,
- }
- return nil
+ return fmt.Errorf("you can't add template without any files")
}
+ tmpl := template.New(name).Funcs(templateFuncMap)
+
tmpl, err := tmpl.ParseFiles(files...)
if err != nil {
return fmt.Errorf("failed to add template into manager: %w", err)
@@ -115,6 +96,5 @@ func (tm *TemplateManager) Add(name string, files ...string) error {
Main: filepath.Base(files[0]),
Tmpl: tmpl,
}
-
return nil
}
diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml
index c7f2df8..49d21ee 100644
--- a/locale/localization.en.yaml
+++ b/locale/localization.en.yaml
@@ -22,17 +22,10 @@ HomePage:
Hymn2: "We've been expecting you!"
Hymn3: 'You bring such joy in Sayasite!'
Hymn4: 'No matter where you roam, know our love is true!~'
-UnsubscribePage:
- Header: 'Cancelling the Subscription'
- UnsetCode: 'Your cancel code is not set. You just having a walk around the site?'
- InvalidCode: "Your cancel code could not be verified. Maybe, the code expired after a day. Or maybe, you've already unsubscribed."
- OnServerError: "We couldn't unsubscribe you, the problem is on our side! We will solve this problem as soon as possible."
- Success: 'Cancelled your subscription successfully! We do hope we will engage you again someday.'
Metadata:
Published: 'Published'
Action: 'Took place on'
Mail:
- UnsubscribeFooter: 'If this letter got you in a bad mood, you can unsubscribe from my blog by {}this link{/}.'
VerifyEmail:
Subject: 'Verification Link for SAYA.TODAY is waiting for you!'
Welcome: 'Greetings!'
@@ -41,9 +34,7 @@ Mail:
InputCode: "Or input this code on the personal page:"
IfRandom: 'If you are unsure what this letter this, you can ignore it, and also, if you are interested, you may visit my site :)'
NewPost:
- Subject: 'A new post arrived at SAYA.TODAY!'
- Intro: 'A new post came!'
- CapturedOn: 'Captured on'
+ Subject: 'New post arrived at SAYA.TODAY!'
UserProfile:
Header: 'Personal Settings'
EmailHeader: 'E-Mail'
@@ -61,14 +52,3 @@ UserProfile:
VerificationSuccess: 'Success! You are now able to subscribe with your new e-mail.'
VerificationFailed: 'Verification code is invalid, please ask administrator for a more detailed cause.'
VerificationEmpty: 'Verification code is unset or empty.'
- SubscriptionHeader: 'Subscribe to...'
- TagsNone: 'None'
- TagsAll: 'All'
- TagsSpecific: 'Specific'
- TagDoesNotExist: 'The tag "{}" does not exist on the site.'
- TagAlreadyAdded: 'The tag "{}" is already in your subscribed list.'
- SaveButton: 'Save'
- SubscribeInvalidType: 'Invalid subscription type.'
- FailedToSubscribe: 'Failed to subscribe by new settings, please ask administrator for a more detailed cause.'
- SubscribedSuccessfully: 'Successfully subscribed you by new settings!'
- RefreshPage: 'Please refresh the profile page on the device, where you requested the code.'
diff --git a/locale/localization.go b/locale/localization.go
index c6467c4..9022b50 100644
--- a/locale/localization.go
+++ b/locale/localization.go
@@ -8,14 +8,13 @@ import (
)
type LocaleConfig struct {
- TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"`
- BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"`
- GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"`
- HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"`
- UnsubscribePage UnsubscribePageConfig `yaml:"UnsubscribePage" json:"UnsubscribePage"`
- Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"`
- Mail MailConfig `yaml:"Mail" json:"Mail"`
- UserProfile UserProfileConfig `yaml:"UserProfile" json:"UserProfile"`
+ TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"`
+ BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"`
+ GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"`
+ HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"`
+ Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"`
+ Mail MailConfig `yaml:"Mail" json:"Mail"`
+ UserProfile UserProfileConfig `yaml:"UserProfile" json:"UserProfile"`
}
type BlogSearchConfig struct {
@@ -47,23 +46,14 @@ type HomePageConfig struct {
Hymn4 string `yaml:"Hymn4" json:"Hymn4"`
}
-type UnsubscribePageConfig struct {
- Header string `yaml:"Header" json:"Header"`
- UnsetCode string `yaml:"UnsetCode" json:"UnsetCode"`
- InvalidCode string `yaml:"InvalidCode" json:"InvalidCode"`
- OnServerError string `yaml:"OnServerError" json:"OnServerError"`
- Success string `yaml:"Success" json:"Success"`
-}
-
type MetadataConfig struct {
Action string `yaml:"Action" json:"Action"`
Published string `yaml:"Published" json:"Published"`
}
type MailConfig struct {
- UnsubscribeFooter string `yaml:"UnsubscribeFooter" json:"UnsubscribeFooter"`
- VerifyEmail VerifyEmailConfig `yaml:"VerifyEmail" json:"VerifyEmail"`
- NewPost NewPostConfig `yaml:"NewPost" json:"NewPost"`
+ VerifyEmail VerifyEmailConfig `yaml:"VerifyEmail" json:"VerifyEmail"`
+ NewPost NewPostConfig `yaml:"NewPost" json:"NewPost"`
}
type VerifyEmailConfig struct {
@@ -76,9 +66,7 @@ type VerifyEmailConfig struct {
}
type NewPostConfig struct {
- Subject string `yaml:"Subject" json:"Subject"`
- CapturedOn string `yaml:"CapturedOn" json:"CapturedOn"`
- Intro string `yaml:"Intro" json:"Intro"`
+ Subject string `yaml:"Subject" json:"Subject"`
}
type UserProfileConfig struct {
@@ -98,17 +86,6 @@ type UserProfileConfig struct {
VerificationSuccess string `yaml:"VerificationSuccess" json:"VerificationSuccess"`
VerificationFailed string `yaml:"VerificationFailed" json:"VerificationFailed"`
VerificationEmpty string `yaml:"VerificationEmpty" json:"VerificationEmpty"`
- SubscriptionHeader string `yaml:"SubscriptionHeader" json:"SubscriptionHeader"`
- TagsNone string `yaml:"TagsNone" json:"TagsNone"`
- TagsAll string `yaml:"TagsAll" json:"TagsAll"`
- TagsSpecific string `yaml:"TagsSpecific" json:"TagsSpecific"`
- TagDoesNotExist string `yaml:"TagDoesNotExist" json:"TagDoesNotExist"`
- TagAlreadyAdded string `yaml:"TagAlreadyAdded" json:"TagAlreadyAdded"`
- SaveButton string `yaml:"SaveButton" json:"SaveButton"`
- SubscribeInvalidType string `yaml:"SubscribeInvalidType" json:"SubscribeInvalidType"`
- FailedToSubscribe string `yaml:"FailedToSubscribe" json:"FailedToSubscribe"`
- SubscribedSuccessfully string `yaml:"SubscribedSuccessfully" json:"SubscribedSuccessfully"`
- RefreshPage string `yaml:"RefreshPage" json:"RefreshPage"`
}
func LoadConfig(path string, config *LocaleConfig) error {
diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml
index 5df82da..fe6d55f 100644
--- a/locale/localization.ru.yaml
+++ b/locale/localization.ru.yaml
@@ -22,17 +22,10 @@ HomePage:
Hymn2: 'Мы Вас столько ждали!'
Hymn3: 'Столько радости от Вас на Саясайте!'
Hymn4: 'Где бы Вы не были, знайте, -- наша любовь крепка!'
-UnsubscribePage:
- Header: 'Отписка'
- UnsetCode: 'Код для отписки не проставлен. Вы случайно не заблудились?'
- InvalidCode: 'Код для отписки не подтверждён. Может, истекло время в сутки для кода. А может, вы уже успели отписаться.'
- OnServerError: 'Мы не смогли вас отписать, проблема на нашей стороне! В скором времени займёмся проблемой.'
- Success: 'Вы отписались от рассылки! Надеюсь, мы ещё сможем вас заинтересовать.'
Metadata:
Published: 'Опубликовано'
Action: 'Время действия'
Mail:
- UnsubscribeFooter: 'Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по {}этой ссылке{/}.'
VerifyEmail:
Subject: 'Ссылка для верификации для подтверждения вашей почты SAYA.TODAY'
Welcome: 'Приветствую!'
@@ -42,8 +35,6 @@ Mail:
IfRandom: 'Если ты не понимаешь, что это за спам, можешь проигнорировать это письмо, а ещё, если интересно, можешь посетить мой сайт :)'
NewPost:
Subject: 'Новый пост на SAYA.TODAY!'
- Intro: 'Вышел новый пост!'
- CapturedOn: 'Снималось'
UserProfile:
Header: 'Личные настройки'
EmailHeader: 'E-Mail'
@@ -61,14 +52,3 @@ UserProfile:
VerificationSuccess: 'Поздравляем! Теперь по этой почте можно подписаться на блог.'
VerificationFailed: 'Код для верификации невалиден, при вопросах, пожалуйста, обращайтесь к администратору.'
VerificationEmpty: 'Код для верификации не введён.'
- SubscriptionHeader: 'Подписаться на...'
- TagsNone: 'Ничего'
- TagsAll: 'Всё'
- TagsSpecific: 'Определённое'
- TagDoesNotExist: 'Тэга "{}" нет на сайте.'
- TagAlreadyAdded: 'Тэг "{}" уже у вас добавлен.'
- SaveButton: 'Сохранить'
- SubscribeInvalidType: 'Неизвестный тип подписки.'
- FailedToSubscribe: 'Неудачная попытка записать новую подписку, более подробно спрашивайте у администратора.'
- SubscribedSuccessfully: 'Вы успешно подписались по новым настройкам!'
- RefreshPage: 'Пожалуйста, обновите страницу с профилем на устройстве, откуда запрашивали код.'
diff --git a/main.go b/main.go
index 3691e5b..2d9dafd 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,8 @@
package main
import (
+ "database/sql"
+ "errors"
"flag"
"log/slog"
"os"
@@ -8,15 +10,51 @@ import (
"syscall"
"github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/internal/glightbox"
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/internal/tailwind"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/dgraph-io/ristretto/v2"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/cors"
+ "github.com/golang-migrate/migrate/v4"
+ "github.com/golang-migrate/migrate/v4/database/sqlite3"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
- _ "github.com/SayaAndy/saya-today-web/internal/router/handlers"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/mattn/go-sqlite3"
)
var (
- configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)")
+ md = goldmark.New(
+ goldmark.WithExtensions(
+ glightbox.NewGLightboxExtension(),
+ tailwind.NewTailwindExtension(),
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ parser.WithAttribute(),
+ ),
+ goldmark.WithRenderer(
+ renderer.NewRenderer(
+ renderer.WithNodeRenderers(
+ util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50),
+ util.Prioritized(html.NewRenderer(html.WithXHTML()), 100),
+ ),
+ ),
+ ),
+ )
+ b2Client *b2.B2Client
+ configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)")
+ availableLanguages = make([]config.AvailableLanguageConfig, 0)
+ localization = make(map[string]*locale.LocaleConfig)
)
func main() {
@@ -33,39 +71,130 @@ func main() {
slog.SetLogLoggerLevel(cfg.LogLevel)
slog.Info("starting sayana-web server...")
- app, err := router.NewRouter(cfg)
+ db, err := sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN)
if err != nil {
- slog.Error("fail to initialize router app", slog.String("error", err.Error()))
+ slog.Error("fail to initialize db", slog.String("error", err.Error()))
os.Exit(1)
}
- if err = app.InitRoutes(); err != nil {
- slog.Error("fail to initialize routes inside router app", slog.String("error", err.Error()))
+ 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()))
+ os.Exit(1)
+ }
+
+ for _, lang := range cfg.AvailableLanguages {
+ localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
+ if err != nil {
+ slog.Warn("fail to initialize a locale", slog.String("locale", lang.Name), slog.String("error", err.Error()))
+ continue
+ }
+ availableLanguages = append(availableLanguages, lang)
+ localization[lang.Name] = localeCfg
+ }
+
+ app := fiber.New(fiber.Config{
+ ProxyHeader: "X-Forwarded-For",
+ })
+
+ app.Use(cors.New(cors.Config{
+ AllowOrigins: "https://f003.backblazeb2.com",
+ AllowMethods: "GET,POST,OPTIONS",
+ AllowHeaders: "Origin, Content-Type, Accept",
+ }))
+
+ router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt))
+ if err != nil {
+ slog.Error("fail to initialize client cache", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ router.PCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
+ NumCounters: 1e6, // 1,000,000
+ MaxCost: 1 << 29, // 512 MB
+ BufferItems: 64, // number of keys per Get buffer.
+ })
+ if err != nil {
+ slog.Error("fail to initialize page cache", slog.String("error", err.Error()))
os.Exit(1)
}
- routerShutdown := make(chan struct{}, 1)
+ router.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, availableLanguages)
+ if err != nil {
+ slog.Error("fail to initialize fact giver", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ router.Mailer, err = mailer.NewMailer(db, cfg.Mail.ClientHost, cfg.Mail.MailHost,
+ cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), localization)
+ if err != nil {
+ slog.Error("fail to initialize mailer", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ app.Get("/", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang<len(2)>", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client))
+ app.Get("/:lang/user", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang/blog", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang/blog/:title", router.Api_V1_GeneralPage(localization, availableLanguages))
+
+ 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.Get("/api/v1/general-page/header", router.Api_V1_GeneralPage_Header(localization, availableLanguages, b2Client))
+ app.Get("/api/v1/general-page/body", router.Api_V1_GeneralPage_Body(localization, availableLanguages, b2Client, md))
+ app.Get("/api/v1/general-page/footer", router.Api_V1_GeneralPage_Footer(localization, availableLanguages))
+ app.Get("/api/v1/general-page/top-embeds", router.Api_V1_GeneralPage_TopEmbeds(localization, availableLanguages))
+ app.Get("/api/v1/general-page/bottom-embeds", router.Api_V1_GeneralPage_BottomEmbeds(localization, availableLanguages, b2Client))
+ app.Post("/api/v1/email/send-verification-code", router.Api_V1_Email_SendVerificationCode(localization))
+ app.Post("/api/v1/email/verify", router.Api_V1_Email_Verify(localization))
+ app.Get("/api/v1/email/is-in-verification", router.Api_V1_Email_IsInVerification(localization))
+
+ app.Static("/", "./static")
go func() {
if err := app.Listen(":3000"); err != nil {
slog.Error("error while running fiber server", slog.String("error", err.Error()))
- if nestedErr := app.Close(); nestedErr != nil {
- slog.Error("error while shutting down fiber server", slog.String("error", err.Error()))
- }
- <-routerShutdown
+ panic(err)
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
- select {
- case <-sigChan:
- slog.Info("gracefully shutting down...")
- if err = app.Close(); err != nil {
- slog.Error("error while shutting down fiber server", slog.String("error", err.Error()))
- }
- case <-routerShutdown:
- slog.Info("gracefully shutting down...")
+ <-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()))
}
+ router.PCache.Close()
+ db.Close()
}
diff --git a/static/input.css b/static/input.css
index 40082c8..cc88959 100644
--- a/static/input.css
+++ b/static/input.css
@@ -21,11 +21,6 @@ html {
--split-right-position: left;
}
-body {
- scrollbar-width: thin;
- scrollbar-color: var(--background-light-color) var(--main-soft-color);
-}
-
@media screen and (min-width: 640px) and (min-height: 640px) {
html {
font-size: 14px;
@@ -51,21 +46,14 @@ body {
}
[data-theme="olive"] {
- --main-dark-color-hard: #4b513a;
- --main-dark-color-medium: #8a9d8a;
- --main-dark-color-soft: #a5b8a5;
- --main-light-color-soft: #ffffdd;
- --main-light-color-medium: var(--background-dark-color);
- --main-light-color-hard: #fffff0;
- --main-hard-color: var(--main-dark-color-hard);
- --main-medium-color: var(--main-dark-color-medium);
- --main-soft-color: var(--main-dark-color-soft);
- --background-dark-color: var(--main-light-color-soft);
- --background-medium-color: var(--main-light-color-medium);
- --background-light-color: var(--main-light-color-hard);
- --sidebar-text-color: var(--main-hard-color);
+ --main-hard-color: #4b513a;
+ --main-medium-color: #8a9d8a;
+ --main-soft-color: #a5b8a5;
+ --background-dark-color: #ffffdd;
+ --background-medium-color: var(--background-dark-color);
+ --background-light-color: #fffff0;
--sidebar-text-color: var(--background-dark-color);
- --sidebar-stroke-color: var(--main-dark-color-hard);
+ --sidebar-stroke-color: var(--main-hard-color);
--secondary-color: var(--main-soft-color);
--leaflet-text-color: var(--main-hard-color);
--leaflet-link-color: var(--main-medium-color);
@@ -80,27 +68,21 @@ body {
}
[data-theme="lettuce"] {
- --main-dark-color-hard: #384a0c;
- --main-dark-color-medium: #617c1f;
- --main-dark-color-soft: #9bb665;
- --main-light-color-soft: #f5eee2;
- --main-light-color-medium: var(--background-dark-color);
- --main-light-color-hard: #fff9ef;
- --main-hard-color: var(--main-dark-color-hard);
- --main-medium-color: var(--main-dark-color-medium);
- --main-soft-color: var(--main-dark-color-soft);
- --background-dark-color: var(--main-light-color-soft);
- --background-medium-color: var(--main-light-color-medium);
- --background-light-color: var(--main-light-color-hard);
+ --main-hard-color: #384a0c;
+ --main-medium-color: #9bb665;
+ --main-soft-color: #bbd095;
+ --background-dark-color: #f5eee2;
+ --background-medium-color: var(--background-dark-color);
+ --background-light-color: #fff9ef;
--sidebar-text-color: var(--main-hard-color);
- --sidebar-stroke-color: var(--main-dark-color-hard);
+ --sidebar-stroke-color: var(--background-dark-color);
--secondary-color: var(--main-medium-color);
--leaflet-text-color: var(--main-hard-color);
--leaflet-link-color: var(--main-medium-color);
--leaflet-link-hover-color: var(--main-soft-color);
- --interlocked-hexagons-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lettuce/linen-fabric.webp'), url('https://f003.backblazeb2.com/file/sayana-static/themes/lettuce/lettuce-tiles.svg');
- --interlocked-hexagons-background-repeat: repeat, repeat;
- --interlocked-hexagons-background-size: 11dvh, 20dvh;
+ --interlocked-hexagons-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23f5ede1%22%2F%3E%3Cpath%20fill%3D%22%239ab663%22%20d%3D%22m33.64%200-.17.25h-33L4.3%206h33.07l4-6zm8.33%200-4.33%206.5h-33l3.83%205.75h33.06L49.7%200zm8.34%200-8.5%2012.75h-33l3.83%205.75H45.7L58.03%200zm8.33%200L45.97%2019h-33l3.83%205.75h33.06L66.36%200zm-8.5%2025.25L33.63%2050l3.86%205.8L54.03%2031h33l-3.83-5.75zm4.16%206.25L37.8%2056.25l3.87%205.8%2016.53-24.8h33l-3.84-5.75zm4.17%206.25L41.97%2062.5l3.86%205.8%2016.54-24.8h33l-3.84-5.75zM62.63%2044l-16.5%2024.75%203.87%205.8%2016.53-24.8h33L95.7%2044zM100%2050.45l-3.87%205.8%203.87%205.81v-11.6zM0%2050.46v11.6L12.5%2080.8l3.86-5.8zm95.83%206.25-3.87%205.8L100%2074.55v-11.6zm-4.17%206.24-3.86%205.8%2012.2%2018.3v-11.6zM0%2062.96v11.6l8.33%2012.49%203.86-5.8zm87.5%206.24L83.63%2075%20100%2099.54V87.96zM50%2075.45%2033.64%20100h7.72l12.5-18.75zm-50%200v11.6l4.16%206.24%203.87-5.8zm54.17%206.25L41.97%20100h7.73l8.33-12.5zm4.17%206.25L50.3%20100h7.73l4.17-6.25zM0%2087.95v11.59l3.86-5.8zm62.5%206.25-3.87%205.8h7.73z%22%2F%3E%3Cpath%20fill%3D%22%23bbd095%22%20d%3D%22M66.8.25%2062.97%206h33l4.03%206.05V.45l-.14-.2zM0%20.45v11.61L12.5%2030.8l3.86-5.8zM62.63%206.5l-3.83%205.75h33l8.2%2012.3v-11.6L95.7%206.5zm-4.16%206.25-3.84%205.75h33L100%2037.06v-11.6l-8.47-12.71zM0%2012.96v11.6l8.33%2012.49%203.86-5.8zM54.3%2019l-3.84%205.75h33L100%2049.55l.01-.01V37.96L87.36%2019zm-4.77%206.25H41.8L25.3%2050l16.66%2025-16.5%2024.75h7.74L49.7%2075%2033.03%2050zm-8.33%200h-7.74L16.96%2050l16.67%2025-16.5%2024.75h7.73L41.36%2075%2024.7%2050Zm-8.34%200h-7.73L8.63%2050%2025.3%2075%208.8%2099.75h7.73L33.03%2075%2016.36%2050zm-16.07%200L.3%2050l16.67%2025L.46%2099.75H8.2L24.7%2075%208.02%2050l16.5-24.75zM0%2025.46v11.6l4.16%206.24%203.87-5.8zm0%2012.5v11.58l3.86-5.79zm66.8%2012.29L50.3%2074.99l3.87%205.81%204.16%206.25%204.17%206.25%204.3%206.45h7.73L58.03%2075l16.5-24.75zm16.06%200h-7.73L58.63%2075l16.5%2024.75h7.73L66.36%2075zm.6%200L66.96%2075l16.5%2024.75h7.74L74.7%2075l16.5-24.75zm16.07%200H91.8L75.3%2075l16.5%2024.75h7.73L83.03%2075z%22%2F%3E%3C%2Fsvg%3E");
+ --interlocked-hexagons-background-repeat: repeat;
+ --interlocked-hexagons-background-size: 10dvh;
--squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23bbd095%22%2F%3E%3Cpath%20fill%3D%22%23f5ede1%22%20d%3D%22M0%200v6.77L6.72%200zm8.91%200L0%208.96v3.86L12.83%200zm6.11%200L0%2015.06v3.87L18.9%200zm6.11%200L0%2021.12v3.92L25%200zM25%200l25%2024.99v-3.87L28.87%200zm25%2024.99%2025%2024.98L100%2025%2075%200zm-50%20.05v3.87l21.13%2021.12H25l-25-25zm25%2024.99h3.87L50%2028.9v-3.87zm0%200L0%2075l25%2025%2025-24.99zM50%2075l25-24.98h-3.87L50%2071.14v3.87zm0%200v3.87L71.13%20100H75L50%2075.01zm25%2025h3.87L100%2078.88v-3.87zm25-24.99v-3.87L78.87%2050.03H75L100%2075zM31.1%200%2050%2018.93v-3.87L34.98%200h-3.87zm6.07%200L50%2012.82V8.96L41.04%200zm6.1%200L50%206.77V0h-6.72zM25%202.19%202.14%2025.04H25zm0%2022.85v22.8l22.81-22.8zM75%203.92l21.13%2021.12L75%2046.16%2053.87%2025.04zm0%202.19L56.06%2025.04l18.89%2018.88%2018.94-18.88L75%206.1zm0%203.92%2015.07%2015L75%2040.06%2059.98%2025.04zm0%202.18L62.17%2025.04%2075%2037.86l12.83-12.82L75%2012.2zm0%203.82%208.96%209L75%2033.95%2066.04%2025zm0%202.24-6.72%206.77L75%2031.76l6.77-6.72h-.05zM0%2031.09v3.87l15.02%2015.07h3.87zm50%200L31.1%2050.03h3.88L50%2034.96V31.1zM0%2037.2v3.87l8.91%208.96h3.92zm50%200L37.17%2050.03h3.87L50%2041.07zM0%2043.3v6.73h6.72zm50%200-6.72%206.73H50zm0%206.73v6.71l6.72-6.71zm8.96%200L50%2058.93v3.92l12.83-12.82zm6.06%200L50%2065.03v3.88l18.94-18.88zm16.09%200L100%2068.9v-3.87l-15.02-15zm6.11%200L100%2062.85v-3.92l-8.91-8.9zm6.06%200%206.72%206.71v-6.71zM75%2052.2%2052.19%2075H75zM75%2075v22.8L97.81%2075zM25%2053.9l21.13%2021.12L25%2096.13%203.87%2075.01zm0%202.2L6.1%2074.95l18.95%2018.88%2018.9-18.88L25%2056.08zm0%203.9%2015.02%2015.01L25%2090.03%209.98%2075zm0%202.19L12.17%2075%2025%2087.84%2037.83%2075%2025%2062.2zm0%203.87L33.96%2075%2025%2083.97%2016.04%2075zm0%202.23-6.72%206.72L25%2081.73l6.72-6.72L25%2068.3zm25%2012.83v3.87L65.02%20100h3.87zm50%200L81.1%20100h3.88L100%2084.99zm-50%206.06v3.86l8.96%208.96h3.87zm50%200L87.22%20100h3.87l8.91-8.96zm-50%206.1V100h6.72zm50%200L93.28%20100H100z%22%2F%3E%3C%2Fsvg%3E");
--squares-and-triangles-background-repeat: repeat;
--squares-and-triangles-background-width-coef: 10;
@@ -108,20 +90,14 @@ body {
}
[data-theme="night"] {
- --main-light-color-hard: var(--color-sky-100);
- --main-light-color-medium: var(--color-sky-200);
- --main-light-color-soft: var(--color-sky-300);
- --main-dark-color-hard: #13131b;
- --main-dark-color-medium: var(--background-dark-color);
- --main-dark-color-soft: #222230;
- --main-hard-color: var(--main-light-color-hard);
- --main-medium-color: var(--main-light-color-medium);
- --main-soft-color: var(--main-light-color-soft);
- --background-dark-color: var(--main-dark-color-hard);
- --background-medium-color: var(--main-dark-color-medium);
- --background-light-color: var(--main-dark-color-soft);
+ --main-hard-color: var(--color-sky-100);
+ --main-medium-color: var(--color-sky-200);
+ --main-soft-color: var(--color-sky-300);
+ --background-dark-color: #13131b;
+ --background-medium-color: var(--background-dark-color);
+ --background-light-color: #222230;
--sidebar-text-color: var(--main-hard-color);
- --sidebar-stroke-color: var(--main-light-color-soft);
+ --sidebar-stroke-color: var(--background-light-color);
--secondary-color: var(--main-soft-color);
--leaflet-text-color: var(--background-dark-color);
--leaflet-link-color: var(--background-dark-color);
@@ -136,20 +112,14 @@ body {
}
[data-theme="ram"] {
- --main-light-color-hard: #fefad8;
- --main-light-color-medium: #f6e4c5;
- --main-light-color-soft: #e8d4b8;
- --main-dark-color-hard: #413b34;
- --main-dark-color-medium: #62584f;
- --main-dark-color-soft: #6d6258;
- --main-hard-color: var(--main-light-color-hard);
- --main-medium-color: var(--main-light-color-medium);
- --main-soft-color: var(--main-light-color-soft);
- --background-dark-color: var(--main-dark-color-hard);
- --background-medium-color: var(--main-dark-color-medium);
- --background-light-color: var(--main-dark-color-soft);
+ --main-hard-color: #fefad8;
+ --main-medium-color: #f6e4c5;
+ --main-soft-color: #e8d4b8;
+ --background-dark-color: #413b34;
+ --background-medium-color: #62584f;
+ --background-light-color: #6d6258;
--sidebar-text-color: var(--main-hard-color);
- --sidebar-stroke-color: var(--main-light-color-soft);
+ --sidebar-stroke-color: var(--background-light-color);
--secondary-color: var(--main-soft-color);
--leaflet-text-color: var(--background-dark-color);
--leaflet-link-color: var(--background-dark-color);
@@ -164,20 +134,14 @@ body {
}
[data-theme="lilac"] {
- --main-dark-color-hard: #343b58;
- --main-dark-color-medium: #414868;
- --main-dark-color-soft: #6c6e75;
- --main-light-color-hard: #e3e6ff;
- --main-light-color-medium: #d1d6ff;
- --main-light-color-soft: #c3caff;
- --main-hard-color: var(--main-dark-color-hard);
- --main-medium-color: var(--main-dark-color-medium);
- --main-soft-color: var(--main-dark-color-soft);
- --background-dark-color: var(--main-light-color-soft);
- --background-medium-color: var(--main-light-color-medium);
- --background-light-color: var(--main-light-color-hard);
+ --main-hard-color: #343b58;
+ --main-medium-color: #414868;
+ --main-soft-color: #6c6e75;
+ --background-dark-color: #c3caff;
+ --background-medium-color: #d1d6ff;
+ --background-light-color: #e3e6ff;
--sidebar-text-color: var(--main-hard-color);
- --sidebar-stroke-color: var(--main-light-color-hard);
+ --sidebar-stroke-color: var(--background-light-color);
--secondary-color: var(--main-medium-color);
--leaflet-text-color: var(--main-hard-color);
--leaflet-link-color: var(--main-medium-color);
@@ -216,8 +180,8 @@ a:hover, .linklike:hover {
text-decoration: underline;
}
-.crossable.disabled::before,
-.crossable.htmx-request::before {
+form.disabled::before,
+form.htmx-request::before {
content: '';
position: absolute;
top: 0;
@@ -232,8 +196,8 @@ a:hover, .linklike:hover {
z-index: 25;
}
-form.crossable.disabled input,
-form.crossable.htmx-request input {
+form.disabled input,
+form.htmx-request input {
filter: grayscale(1);
}
@@ -263,11 +227,11 @@ form.crossable.htmx-request input {
}
.text-sidebar {
- color: var(--main-dark-color-soft);
- text-shadow: -0.2rem 0 var(--main-dark-color-hard), 0 -0.2rem var(--main-dark-color-hard), 0.2rem 0rem var(--main-light-color-soft), 0 0.2rem var(--main-light-color-soft);
- -webkit-text-stroke-color: var(--sidebar-stroke-color);
- -webkit-text-stroke-width: 0.1rem;
- background: linear-gradient(135deg, color-mix(in srgb, var(--main-light-color-soft) 20%, transparent), color-mix(in srgb, var(--main-dark-color-soft) 20%, transparent));
+ color: var(--sidebar-text-color);
+}
+
+.stroke-sidebar {
+ border-color: var(--sidebar-stroke-color);
}
.text-main-hard {
@@ -296,22 +260,22 @@ form.crossable.htmx-request input {
.bg-main-hard {
background-color: var(--main-hard-color);
- background-image: var(--noise-image);
+ background-image: url(data:image/png;base64,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);
}
.bg-main-medium {
background-color: var(--main-medium-color);
- background-image: var(--noise-image);
+ background-image: url(data:image/png;base64,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);
}
.bg-main-soft {
background-color: var(--main-soft-color);
- background-image: var(--noise-image);
+ background-image: url(data:image/png;base64,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);
}
.bg-background-dark {
background-color: var(--background-dark-color);
- background-image: var(--noise-image);
+ background-image: url(data:image/png;base64,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);
}
.bg-background-medium {
@@ -321,7 +285,7 @@ form.crossable.htmx-request input {
.bg-background-light {
background-color: var(--background-light-color);
- background-image: var(--noise-image);
+ background-image: url(data:image/png;base64,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);
}
.border-main-hard {
@@ -446,57 +410,6 @@ form.crossable.htmx-request input {
border-radius: 50%;
}
-.themed-button::before {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: linear-gradient(135deg, var(--main-light-color-hard), var(--main-dark-color-hard));
- opacity: 0.2;
- z-index: 10;
- content: "";
-}
-
-.themed-button:active::before {
- opacity: 0.4;
-}
-
-.themed-button:hover:not(:active)::before {
- opacity: 0;
-}
-
-.themed-button {
- border-top: var(--border-size) solid var(--main-light-color-hard);
- border-left: var(--border-size) solid var(--main-light-color-hard);
- border-bottom: var(--border-size) solid var(--main-dark-color-hard);
- border-right: var(--border-size) solid var(--main-dark-color-hard);
- position: relative;
- --border-size: 0.5rem;
-}
-
-.themed-button:active, .themed-button.active {
- border-top: var(--border-size) solid var(--main-dark-color-hard);
- border-left: var(--border-size) solid var(--main-dark-color-hard);
- border-bottom: var(--border-size) solid var(--main-light-color-hard);
- border-right: var(--border-size) solid var(--main-light-color-hard);
-}
-
-.themed-button:active > *, .themed-button.active > * {
- transform: translateY(calc(var(--border-size) / 2));
-}
-
-.themed-button.themed-button-sidebar {
- --border-size: 0.25rem;
- background-image: var(--squares-and-triangles-background);
- background-repeat: var(--squares-and-triangles-background-repeat);
- background-size: calc(var(--squares-and-triangles-background-width-coef) * 0.9rem) calc(var(--squares-and-triangles-background-height-coef) * 0.9rem);
-}
-
-.themed-button.themed-button-like {
- --border-size: 0.2rem;
-}
-
.custom-svg-marker {
background: transparent;
border: none;
@@ -555,11 +468,15 @@ form.crossable.htmx-request input {
}
.grid-item {
+ position: relative;
+ display: inline-block;
width: 10rem;
}
.grid-sizer {
- width: 10.5rem;
+ position: relative;
+ display: inline-block;
+ width: 10.1rem;
}
.grid-item-index {
@@ -591,7 +508,7 @@ form.crossable.htmx-request input {
}
.grid-sizer {
- width: 12.5rem;
+ width: 12.1rem;
}
.grid-item-2x {
@@ -600,10 +517,14 @@ form.crossable.htmx-request input {
}
@container main (width >= 76rem) {
- .grid-item, .grid-sizer {
+ .grid-item {
width: 18rem;
}
+ .grid-sizer {
+ width: 18.1rem;
+ }
+
.grid-item-2x {
width: 36rem;
}
@@ -615,7 +536,7 @@ form.crossable.htmx-request input {
}
.grid-sizer {
- width: 24.5rem;
+ width: 24.1rem;
}
.grid-item-2x {
diff --git a/views/layouts/general-mail.html b/views/layouts/general-mail.html
index 8f42c7a..c73a1ec 100644
--- a/views/layouts/general-mail.html
+++ b/views/layouts/general-mail.html
@@ -22,6 +22,7 @@
html {
font-family: 'Philosopher', sans-serif;
+ color: #343b58;
--noise-image: url(data:image/png;base64,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);
}
@@ -30,8 +31,12 @@ body {
}
a {
+ color: #c3caff !important;
text-decoration: underline;
- text-shadow: none !important;
+}
+
+a:hover {
+ color: #d1d6ff !important;
}
.header {
@@ -48,8 +53,8 @@ a {
}
.content {
- margin: 0 32px 0 32px;
- padding: 16px 16px 32px 16px;
+ margin: 0rem 32px 32px 32px;
+ padding: 16px;
font-size: 24px;
text-shadow: -1px 0 #343b58, 0 1px #343b58, 1px 0 #343b58, 0 -1px #343b58;
color: white;
@@ -57,35 +62,11 @@ a {
background-image: var(--noise-image);
}
-.footer {
- margin: 0;
- padding: 1px 8px 1px 8px;
- font-size: 16px;
- color: white;
-}
-
-.darkened {
- background-color: #9aa5f4;
- background-image: var(--noise-image);
-}
-
.outlier {
padding-inline: 4px;
background-color: #6c6e75;
background-image: var(--noise-image);
}
-
-table, th, td {
- border: 1px solid black;
-}
-
-td {
- padding: 5px;
-}
-
-img {
- width: 200px;
-}
</style>
</head>
@@ -94,12 +75,8 @@ img {
saya.today
</div>
- <div id="content" class="content" style="background-color: #e3e6ff !important;">
+ <div id="content" class="content">
{{ block "body" . }}{{ end }}
</div>
-
- <div id="footer" class="footer darkened">
- {{ block "footer" . }}{{ end }}
- </div>
</body>
</html>
diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html
index 01043c6..48a0f16 100644
--- a/views/layouts/general-page.html
+++ b/views/layouts/general-page.html
@@ -9,7 +9,7 @@
<link href="/output.css" rel="stylesheet">
</head>
-<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh flex flex-row">
+<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-[100dvh] flex flex-row">
<script>
let map = null;
@@ -137,7 +137,6 @@
document.body.append(frame);
}
break;
- case "lilac": break;
}
}
@@ -172,29 +171,24 @@
<div class="grow bg-split-left"></div>
- <div class="flex flex-col sm:flex-row">
- <div id="sidebar" class="bg-sidebar flex
- flex-row sticky w-dvw h-fit xs:w-[90dvw]
- sm:left-0 sm:flex-col sm:fixed sm:w-fit
- z-20 shadow-[0_0_1rem_black]">
- <div class="text-[3rem] flex flex-row sm:flex-col gap-0 relative z-20">
- <a onclick="return changeUrl('/../', true);" href="./" id="sidebar-back-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false">
- <i class="fas fa-circle-left flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <div class="flex flex-col [@media(max-height:32rem)]:flex-row">
+ <div id="sidebar" class="bg-sidebar flex sticky flex-col z-20 w-[100dvw] xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] [@media(max-height:32rem)]:w-[5rem] h-[5rem] [@media(max-height:32rem)]:h-[100dvh] shadow-[0_0_1rem_black]">
+ <div class="text-[3rem] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1dvw] flex flex-row [@media(max-height:32rem)]:flex-col gap-0 relative z-20">
+ <a href="./" id="sidebar-back-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/../', true);" class="fas fa-circle-left flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a onclick="return changeUrl('/{{ .Lang }}');" href="/{{ .Lang }}" id="sidebar-home-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false">
- <i class="fas fa-home flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <a href="/{{ .Lang }}" id="sidebar-home-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/{{ .Lang }}');" class="fas fa-home flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a onclick="return changeUrl('/{{ .Lang }}/blog');" href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false">
- <i class="fas fa-search flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <a href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/{{ .Lang }}/blog');" class="fas fa-search flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false">
- <i class="fas fa-map flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="w-[5rem] h-[5rem] hidden">
+ <i class="fas fa-map flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <div id="toolbar-theme-button" onclick="toggleThemeWheel();" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false">
- <i id="toolbar-theme-button-content" class="fas fa-swatchbook flex w-18 h-18 content-center text-center text-sidebar"></i>
- </div>
- <a onclick="return changeUrl('/{{ .Lang }}/user');" href="/{{ .Lang }}/user" id="sidebar-user-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false">
- <i class="fas fa-user flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <i onclick="toggleThemeWheel();" id="sidebar-theme-button" class="fas fa-swatchbook flex w-[5rem] h-[5rem] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 rounded-lg"></i>
+ <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
</div>
<div class="theme-wheel" id="theme-wheel">
@@ -240,10 +234,10 @@
</script>
</div>
- <div class="bg-background-dark @container/main flex z-10 w-dvw xs:w-[90dvw] sm:w-[85dvw] md:w-[80dvw] lg:w-[75dvw] xl:w-[70dvw] 2xl:w-[60dvw] 3xl:w-[50dvw] grow-0 h-[calc(100dvh-5rem)] sm:h-dvh shadow-[0_0_1rem_black]">
+ <div class="bg-background-dark @container/main flex z-10 w-[100dvw] xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] grow-0 h-[calc(100dvh-5rem)] [@media(max-height:32rem)]:h-[100dvh] shadow-[0_0_1rem_black]">
<div class="bg-background-dark border-r-2 border-dashed border-main-hard"></div>
- <div class="max-h-full max-w-full flex flex-col grow bg-background-dark relative pt-4 ml-8 pr-4">
+ <div class="max-h-100% flex flex-col grow bg-background-dark relative pt-4 ml-8 pr-4">
<div id="general-page-header" class="max-xs:hidden [@media(max-height:32rem)]:hidden flex flex-col basis-8"
hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
</div>
@@ -251,7 +245,7 @@
<hr class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-4 border-dotted border-main-hard mb-2">
- <div id="general-page-body" class="relative flex flex-col grow overflow-y-auto"
+ <div id="general-page-body" class="flex flex-col grow overflow-y-auto"
hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
</div>
<div id="general-page-body-trigger" hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-body" hx-swap="innerHTML"></div>
@@ -273,40 +267,7 @@
<script src="https://f003.backblazeb2.com/file/sayana-static/libs/htmx/2.0.6/htmx.min.js"></script>
<script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script>
<script src="https://f003.backblazeb2.com/file/sayana-static/libs/imagesloaded/5.0.0/imagesloaded.pkgd.min.js"></script>
- <script src="https://f003.backblazeb2.com/file/sayana-static/libs/packery/3.0.0/dist/packery.pkgd.min.js"></script>
-
- <script>
- // Enable fitWidth-like attribute for Packery. Source: https://codepen.io/desandro/pen/kVmLYz
- var __resetLayout = Packery.prototype._resetLayout;
- Packery.prototype._resetLayout = function() {
- __resetLayout.call( this );
- var parentSize = getSize( this.element.parentNode );
- var colW = this.columnWidth + this.gutter;
- this.fitWidth = Math.floor( ( parentSize.innerWidth + this.gutter ) / colW ) * colW;
- this.packer.width = this.fitWidth;
- this.packer.height = Number.POSITIVE_INFINITY;
- this.packer.reset();
- };
-
- Packery.prototype._getContainerSize = function() {
- var emptyWidth = 0;
- for ( var i=0, len = this.packer.spaces.length; i < len; i++ ) {
- var space = this.packer.spaces[i];
- if ( space.y === 0 && space.height === Number.POSITIVE_INFINITY ) {
- emptyWidth += space.width;
- }
- }
-
- return {
- width: this.fitWidth - this.gutter - emptyWidth,
- height: this.maxY - this.gutter
- };
- };
-
- Packery.prototype.needsResizeLayout = function() {
- return true;
- };
- </script>
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/masonry-layout/4.2.2/masonry.pkgd.min.js"></script>
<div id="general-page-bottom-embeds" class="flex"
hx-get="/api/v1/general-page/bottom-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
@@ -315,7 +276,7 @@
<script>
const themeWheel = document.getElementById('theme-wheel');
- const themeButton = document.getElementById('toolbar-theme-button');
+ const themeButton = document.getElementById('sidebar-theme-button');
const themeIcons = document.querySelectorAll('.theme-icon');
const themeWheelRadius = convertRemToPixels(6);
@@ -431,7 +392,7 @@
themeWheel.addEventListener('mouseleave', hideThemeWheel);
document.addEventListener('click', (e) => {
- if (!themeWheel.contains(e.target) && e.target !== themeButton && !e.target.classList.contains("toolbar-theme-button-content")) {
+ if (!themeWheel.contains(e.target) && e.target !== themeButton) {
hideThemeWheel();
}
});
diff --git a/views/messages/new-post.html b/views/messages/new-post.html
index f1338f7..4fbfc02 100644
--- a/views/messages/new-post.html
+++ b/views/messages/new-post.html
@@ -1,19 +1,3 @@
{{ define "body" }}
-<p>{{ .L.Mail.NewPost.Intro }}</p>
-<table>
- <tr>
- <td rowspan="3"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}"><img src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Post.Metadata.Thumbnail }}.webp"></a></td>
- <td class="darkened" style="font-size: 24px"><a style="color: #273de1 !important;" href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}">{{ .Post.Metadata.Title }}</a></td>
- </tr>
- <tr>
- <td class="darkened" style="font-size: 16px">{{ .Post.Metadata.ShortDescription }}</td>
- </tr>
- <tr>
- <td class="darkened" style="font-size: 16px">{{ .L.Mail.NewPost.CapturedOn }} {{ .Post.Metadata.ActionDate }}</td>
- </tr>
-</table>
-{{ end }}
-
-{{ define "footer" }}
-<p>{{ .UnsubscribeFooter }}</p>
-{{ end }}
+<p>Новый пост ёпта.</p>
+{{ end }} \ No newline at end of file
diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html
index d2c74ae..8e91798 100644
--- a/views/pages/blog-catalogue.html
+++ b/views/pages/blog-catalogue.html
@@ -1,68 +1,61 @@
{{ define "body" }}
-<div class="flex flex-row ar-lt-0.8:flex-col text-base h-full">
- <div class="relative flex shrink-0 ar-gt-0.8:min-w-[10dvh] ar-gt-0.8:max-w-[20dvh] ar-gt-0.8:w-fit ar-lt-0.8:w-full ar-lt-0.8:max-h-[30%] ar-gt-0.8:mr-6">
- <div class="absolute w-full h-full inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
- <form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML"
- class="tags-list relative flex flex-col w-full bg-background-light">
- <div class="flex flex-col overflow-y-auto">
- <fieldset>
- <legend class="font-bold w-full text-center">{{ .L.BlogSearch.TagsHeader }}</legend>
- <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 xs:grid-cols-4 sm:grid-cols-5 grid-flow-row-dense">
- <div class="m-1">
- <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label>
- </div>
- {{- range .Tags }}
- <div class="m-1">
- <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 class="flex flex-row ar-lt-0.8:flex-col text-base h-[100%]">
+ <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-[10dvh] ar-gt-0.8:max-w-[20dvh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] ar-lt-0.8:max-h-[30%] flex shrink-0 flex-col ar-gt-0.8:mr-6 bg-background-light inset-shadow-[0_0_0.4rem_black]">
+ <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-3 xs:grid-cols-4 sm:grid-cols-5 grid-flow-row-dense">
+ <div class="m-1">
+ <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label>
</div>
- </fieldset>
- <fieldset class="mt-1">
- <legend class="font-bold w-full 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-1">
- <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-1">
- <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-1">
- <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-1">
- <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-1">
- <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-1">
- <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>
+ {{- range .Tags }}
+ <div class="m-1">
+ <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>
- </fieldset>
- </div>
- <hr class="border-t-2 border-dotted border-main-hard my-0.5">
- <div class="flex flex-row bg-background-dark my-2 p-0.5 rounded-1 justify-items-center">
- <i onclick="defaultSearchParams(); cleanHrefParams();" class="fas fa-rotate-left flex-1 text-center text-2xl hover:bg-background-light cursor-pointer transition-colors duration-300"></i>
- <div class="w-1 bg-background-light grow-0"></div>
- <button class="flex-1" type="submit">
- <i onclick="setHrefParams();" class="fas fa-magnifying-glass text-center text-2xl hover:bg-background-light cursor-pointer transition-colors duration-300"></i>
- </button>
- </div>
- </form>
- </div>
+ {{- end }}
+ </div>
+ </fieldset>
+ <fieldset class="mt-1">
+ <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-1">
+ <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-1">
+ <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-1">
+ <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-1">
+ <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-1">
+ <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-1">
+ <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>
+ <hr class="border-t-2 border-dotted border-main-hard my-0.5">
+ <div class="flex flex-row bg-background-dark my-2 p-0.5 rounded-1 justify-items-center">
+ <i onclick="defaultSearchParams(); cleanHrefParams();" class="fas fa-rotate-left flex-1 text-center text-2xl hover:bg-background-light cursor-pointer transition-colors duration-300"></i>
+ <div class="w-1 bg-background-light grow-0"></div>
+ <button class="flex-1" type="submit">
+ <i onclick="setHrefParams();" class="fas fa-magnifying-glass text-center text-2xl hover:bg-background-light cursor-pointer transition-colors duration-300"></i>
+ </button>
+ </div>
+ </form>
<div class="w-[0.5dvh] border-r-2 border-dotted border-main-hard bg-background-dark"></div>
- <div class="relative flex grow h-full">
- <div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
- <div hx-get="/api/v1/blog-search" hx-vals='js:{lang: "{{ .Lang }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load" hx-include=".tags-list"
- class="blog-cards relative grow flex flex-col overflow-y-auto bg-background-light p-6">
- Loading...
- </div>
+ <div hx-get="/api/v1/blog-search" hx-vals='js:{lang: "{{ .Lang }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load" hx-include=".tags-list"
+ class="blog-cards grow flex flex-col overflow-y-auto bg-background-light inset-shadow-[0_0_0.4rem_black] p-6">
+ Loading...
</div>
-
</div>
<script>
diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html
index c81406f..7170ad8 100644
--- a/views/pages/blog-page.html
+++ b/views/pages/blog-page.html
@@ -1,16 +1,9 @@
{{ define "body" }}
-<div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
-<div class="relative bg-background-light flex flex-col px-8 py-4 overflow-y-auto">
+<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4 overflow-y-auto">
<p class="max-xs:block [@media(max-height:32rem)]:block hidden font-andika text-2xl font-bold italic text-main-hard tracking-[.0125rem] mb-1">{{ .Title }}</p>
- <p class="block grow text-lg font-andika text-secondary">
- <b>{{ .L.Metadata.Published }}</b>:
- <span hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load">
- {{ .PublishedDate }}
- </span>
- </p>
- <p class="block grow text-lg font-andika text-secondary"><b>{{ .L.Metadata.Action }}</b>: {{ .ActionDate }}</p>
- <p class="block grow text-md font-andika italic text-main-hard">{{ .ShortDescription }}</p>
- <hr class="block border-t-2 border-dotted border-main-hard mt-1 mb-2 w-full ml-auto mr-auto">
+ <p class="max-xs:block [@media(max-height:32rem)]:block hidden grow text-lg font-andika text-secondary">{{ .L.Metadata.Published }}: {{ .PublishedDate }}</p>
+ <p class="max-xs:block [@media(max-height:32rem)]:block hidden grow text-lg font-andika text-secondary">{{ .L.Metadata.Action }}: {{ .ActionDate }}</p>
+ <hr class="max-xs:block [@media(max-height:32rem)]:block hidden border-t-2 border-dotted border-main-hard mt-1 mb-2 w-full ml-auto mr-auto">
{{ .ParsedMarkdown }}
{{- if .MapLocationX }}
<hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto">
@@ -21,9 +14,9 @@
function initLocationMap(id, x, y, relativeErrorMeters = 0) {
map = L.map(id).setView([x, y], 8);
- L.tileLayer('https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', {
- maxZoom: 20,
- attribution: '<a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> | Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
if (relativeErrorMeters != 0) {
@@ -63,6 +56,6 @@
</div>
{{ end }}
-{{ define "header" }}
+{{ define "footer" }}
<div hx-get="/api/v1/like" hx-target="this" hx-swap="outerHTML" hx-trigger="load"></div>
{{ end }}
diff --git a/views/pages/global-map.html b/views/pages/global-map.html
index 7d788d6..8be99bf 100644
--- a/views/pages/global-map.html
+++ b/views/pages/global-map.html
@@ -12,7 +12,7 @@
<link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css">
</head>
-<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh">
+<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-[100dvh]">
<script>
function switchTheme(theme) {
@@ -42,25 +42,23 @@
}
</script>
- <div id="sidebar" class="bg-sidebar block fixed shadow-[0_0_0.4rem_black] z-1001 w-fit h-auto">
- <div class="text-[3rem] flex flex-col gap-0 relative z-1001">
- <a href="./" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false">
- <i class="fas fa-circle-left flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <div id="sidebar" class="bg-sidebar block fixed shadow-[0_0_0.4rem_black] z-1001 w-[5rem] h-auto">
+ <div class="text-[3rem] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1dvw] flex flex-col gap-0 relative z-1001">
+ <a href="./" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-circle-left flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a href="/{{ .Lang }}" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false">
- <i class="fas fa-home flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <a href="/{{ .Lang }}" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-home flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a href="/{{ .Lang }}/blog" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false">
- <i class="fas fa-search flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <a href="/{{ .Lang }}/blog" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-search flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a href="/{{ .Lang }}/map" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false">
- <i class="fas fa-map flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <a href="/{{ .Lang }}/map" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-map flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <div id="toolbar-theme-button" onclick="toggleThemeWheel();" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false">
- <i id="toolbar-theme-button-content" class="fas fa-swatchbook flex w-18 h-18 content-center text-center text-sidebar"></i>
- </div>
- <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false">
- <i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-18 h-18 content-center text-center text-sidebar"></i>
+ <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook flex w-[5rem] h-[5rem] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 rounded-lg"></i>
+ <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="w-[5rem] h-[5rem]">
+ <i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
</div>
<div class="theme-wheel" id="theme-wheel">
@@ -81,7 +79,7 @@
<script>
const themeWheel = document.getElementById('theme-wheel');
- const themeButton = document.getElementById('toolbar-theme-button');
+ const themeButton = document.getElementById('theme-button');
const themeIcons = document.querySelectorAll('.theme-icon');
const themeWheelRadius = convertRemToPixels(6);
@@ -197,7 +195,7 @@
themeWheel.addEventListener('mouseleave', hideThemeWheel);
document.addEventListener('click', (e) => {
- if (!themeWheel.contains(e.target) && e.target !== themeButton && !e.target.classList.contains("toolbar-theme-button-content")) {
+ if (!themeWheel.contains(e.target) && e.target !== themeButton) {
hideThemeWheel();
}
});
@@ -230,9 +228,9 @@
function initLocationMap() {
map = L.map('map-container').setView([{{ .MapLocationLat }}, {{ .MapLocationLong }}], 4);
- L.tileLayer('https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', {
- maxZoom: 20,
- attribution: '<a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> | Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
zoomControl: false
}).addTo(map);
diff --git a/views/pages/home-page.html b/views/pages/home-page.html
index 6d397fa..11d0f7b 100644
--- a/views/pages/home-page.html
+++ b/views/pages/home-page.html
@@ -1,21 +1,20 @@
{{ define "body" }}
-<div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
-<div class="relative bg-background-light flex flex-row ar-lt-0.6:flex-col w-full h-full overflow-y-auto text-base/loose tracking-wide z-10">
+<div class="bg-background-light flex flex-row ar-lt-0.6:flex-col w-full h-full inset-shadow-[0_0_0.4rem_black] overflow-y-auto text-base/[2] tracking-wide">
<div class="ar-gt-0.6:flex-3/5 justify-center content-start p-8">
<div class="flex flex-col justify-start items-start bg-background-dark mb-8 shadow-[0.4rem_0.4rem_0.4rem_black]">
- <p class="text-3xl/loose sm:text-4xl/loose lg:text-5xl/loose tracking-widest uppercase w-full font-andika italic text-left
+ <p class="text-3xl/[2] sm:text-4xl/[2] lg:text-5xl/[2] tracking-widest uppercase w-full font-andika italic text-left
border-l-4 border-solid border-(--background-dark-color)
- bg-linear-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Hymn1 }}</p>
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Hymn1 }}</p>
<hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
- <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn2 }}</p>
- <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn3 }}</p>
- <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn4 }}</p>
+ <p class="text-xl/[2] sm:text-2xl/[2] lg:text-3xl/[2] -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn2 }}</p>
+ <p class="text-xl/[2] sm:text-2xl/[2] lg:text-3xl/[2] -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn3 }}</p>
+ <p class="text-xl/[2] sm:text-2xl/[2] lg:text-3xl/[2] -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn4 }}</p>
</div>
<div class="flex flex-col justify-center items-center">
<p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-andika italic text-left
border-l-1.5 border-solid border-(--background-dark-color)
- bg-linear-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.DidYouKnowThat }}</p>
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.DidYouKnowThat }}</p>
<hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
<ul class="w-full text-left list-[circle]">
<li class="ml-4 mb-1">{{ index .FunFacts 0 }}</li>
@@ -29,7 +28,7 @@
<div class="flex flex-col justify-center items-start mb-4">
<p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-andika italic text-left
border-l-1.5 border-solid border-(--background-dark-color)
- bg-linear-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Contacts }}</p>
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Contacts }}</p>
<hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
<a href="https://t.me/EarlInisMona">
<i class="fa-brands fa-telegram w-4 h-4 mr-1"></i>
@@ -104,7 +103,7 @@
<div class="flex flex-col justify-center items-center">
<p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-andika italic text-left
border-l-1.5 border-solid border-(--background-dark-color)
- bg-linear-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.SidebarDescription }}</p>
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.SidebarDescription }}</p>
<hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
<div class="w-full grid grid-cols-[1fr_4fr] content-center items-center
[&>*:nth-child(4n+2)]:bg-(--background-medium-color)/50 [&>*:nth-child(4n+3)]:bg-(--background-medium-color)/50" style="container-type: inline-size;">
diff --git a/views/pages/language-pick.html b/views/pages/language-pick.html
index 775c18e..5a9d229 100644
--- a/views/pages/language-pick.html
+++ b/views/pages/language-pick.html
@@ -1,5 +1,5 @@
{{ define "body" }}
-<div class="flex flex-col justify-center text-3xl h-full">
+<div class="flex flex-col justify-center text-3xl h-[100%]">
{{- range .AvailableLanguages }}
<hr class="border-t-2 border-dotted border-main-hard">
<div class="nth-[2n+1_of_div]:bg-(--background-medium-color)/50 nth-[2n_of_div]:bg-(--background-light-color)/50 p-2.5 flex flex-row justify-center justify-items-center">
diff --git a/views/pages/unsubscribe-page.html b/views/pages/unsubscribe-page.html
deleted file mode 100644
index 3876818..0000000
--- a/views/pages/unsubscribe-page.html
+++ /dev/null
@@ -1,30 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>{{ .L.UnsubscribePage.Header }} // SAYA TODAY</title>
-<style>
- html {
- --noise-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==);
- }
-</style>
-</head>
-
-<body style="width: 100dvw; height: 100dvh; display: flex; flex-direction: column; position: fixed">
-
- <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.1)'></div>
-
- <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;">
- <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 2rem;">{{ .StatusEmoji }}</p>
- </div>
-
- <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;">
- <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 1.5rem;">{{ .StatusText }}</p>
- </div>
-
- <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.2)'></div>
-
-</body>
-
-</html>
diff --git a/views/pages/user-page.html b/views/pages/user-page.html
index fda7e66..1632670 100644
--- a/views/pages/user-page.html
+++ b/views/pages/user-page.html
@@ -1,10 +1,9 @@
{{ define "body" }}
-<div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
-<div class="relative bg-background-light flex flex-col px-8 py-4">
- <form id="email-form" class="w-full mb-2 flex flex-col relative crossable"
+<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4 overflow-y-auto">
+ <form id="email-form" class="w-full mb-2 flex flex-col relative"
hx-get="/api/v1/email/is-in-verification" hx-target="#email-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this">
<div class="flex flex-row w-full mb-4 z-21">
- <div class="flex flex-col w-full sm:flex-3/5 grow-0">
+ <div class="flex flex-col flex-3/5 grow-0">
<label class="block text-main-medium font-bold mb-1 z-22">
{{ .L.UserProfile.EmailHeader }}
</label>
@@ -13,78 +12,30 @@
data-validated-email="{{ .Email }}" onchange="checkIfValidated(this);">
</div>
</div>
- <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
+ <button class="w-1/3 mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
hx-post="/api/v1/email/send-verification-code" hx-target="#email-message" hx-swap="outerHTML" hx-indicator="#email-form">
{{ .L.UserProfile.SendCodeButton }}
</button>
</form>
- <div id="email-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
-
+ <div id="email-message"></div>
<hr class="my-2 border-t-4 border-dotted border-main-hard">
-
- <form id="email-verification-form" class="w-full mb-2 flex flex-col relative hidden crossable"
+ <form id="email-verification-form" class="w-full mb-2 flex flex-col relative hidden"
{{ if .EmailCode }}hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"{{ end }}>
<div class="flex flex-row w-full mb-4 z-21">
- <div class="flex flex-col w-full sm:flex-2/5 grow-0">
+ <div class="flex flex-col flex-2/5 grow-0">
<label class="block text-main-medium font-bold ml-1 z-22">
{{ .L.UserProfile.VerificationCodeHeader }}
</label>
<input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22"
- name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16" autocapitalize="characters">
+ name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16">
</div>
</div>
- <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
+ <button class="w-1/3 mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-indicator="#email-verification-form">
{{ .L.UserProfile.VerifyButton }}
</button>
</form>
- <div id="verification-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
-</div>
-
-<hr class="my-4 border-t-4 border-dotted border-main-hard">
-
-<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4">
- <form id="subs-form" class="w-full mb-2 flex flex-col relative crossable">
- <div class="flex flex-col w-full mb-4 z-21">
- <label class="block text-main-medium font-bold mb-1 z-22">
- {{ .L.UserProfile.SubscriptionHeader }}
- </label>
- <div class="flex flex-col sm:flex-row w-full">
- <div class="m-1 w-1/3">
- <label><input class="mr-1" type="radio" id="tagsNone" name="tags" value="none" {{ if eq .TagsPicked "none" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsNone }}</label>
- </div>
- <div class="m-1 w-1/3">
- <label><input class="mr-1" type="radio" id="tagsAll" name="tags" value="all" {{ if eq .TagsPicked "all" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsAll }}</label>
- </div>
- <div class="m-1 w-1/3">
- <label><input class="mr-1" type="radio" id="tagsSpecific" name="tags" value="specific" {{ if eq .TagsPicked "specific" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsSpecific }}</label>
- </div>
- </div>
- <div id="pick-tags-form" class="flex flex-col w-full relative crossable">
- <input type="text" class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22"
- list="existing-tags" onchange="addTag(this.value);" onkeydown="if (event.keyCode === 13) {addTag(this.value); this.value=''; return false;}">
- <datalist id="existing-tags">
- {{- range .ExistingTags }}
- <option value="{{ .Name }}">
- {{- end }}
- </datalist>
- <div id="tags-picked-list" class="bg-background-dark flex flex-wrap inset-shadow-[0_0_0.4rem_black] p-2 m-4 w-full">
- {{- range .TagsPickedList }}
- <div id="tagPicked{{ . }}" class="bg-main-hard text-background-light m-2 flex flex-row">
- <p class="py-1 px-2">{{ . }}</p>
- <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked{{ . }}'));">✘</p>
- <input type="hidden" name="tags_picked" value="{{ . }}">
- </div>
- {{- end }}
- </div>
- </div>
- <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
- hx-put="/api/v1/subs" hx-target="#subs-message" hx-swap="outerHTML" hx-indicator="#subs-form">
- {{ .L.UserProfile.SaveButton }}
- </button>
- </div>
- </form>
- <div id="subs-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
+ <div id="verification-message"></div>
</div>
<script>
@@ -106,9 +57,7 @@ htmx.on("htmx:afterSwap", (e) => {
emailMessage = htmx.find("#email-message");
verificationMessage = htmx.find("#verification-message");
emailForm = htmx.find("#email-form");
- emailInput = htmx.find("#email-input");
emailVerificationForm = htmx.find("#email-verification-form");
- subsForm = htmx.find("#subs-form");
tryAgainTimeDiff = parseInt(emailMessage.dataset.strikedEndTime || "0") - new Date().getTime();
verificationWaitingTimeDiff = parseInt(emailMessage.dataset.codeExpiryTime || "0") - new Date().getTime();
@@ -132,12 +81,6 @@ htmx.on("htmx:afterSwap", (e) => {
if ((verificationMessage.dataset.hideVerificationPanel || "false") == "true") {
emailVerificationForm.classList.add("hidden");
}
-
- if (emailInput.dataset.validatedEmail != "") {
- subsForm.classList.remove("disabled");
- } else {
- subsForm.classList.add("disabled");
- }
});
function checkIfValidated(elem) {
@@ -149,58 +92,5 @@ function checkIfValidated(elem) {
}
checkIfValidated(htmx.find("#email-input"));
-
-function toggleTagsPick(elem) {
- const pickTagsForm = htmx.find("#pick-tags-form");
- if (elem.value === "specific" && elem.checked) {
- pickTagsForm.classList.remove("disabled");
- } else {
- pickTagsForm.classList.add("disabled");
- }
-}
-
-toggleTagsPick(htmx.find("#tagsSpecific"));
-
-function addTag(name) {
- name = name.trim();
- const subsMessage = htmx.find("#subs-message");
- const existingTags = htmx.findAll(htmx.find("#existing-tags"), "option");
- found = [...existingTags].find(opt => opt.value===name);
- if (!found) {
- subsMessage.classList.remove("hidden");
- subsMessage.innerHTML = "{{ .L.UserProfile.TagDoesNotExist }}".replace("{}", name);
- return;
- }
-
- const tagsPickedList = htmx.find("#tags-picked-list");
- const subscribedTags = htmx.findAll(tagsPickedList, "div input");
- found = [...subscribedTags].find(opt => opt.value===name);
- if (found) {
- subsMessage.classList.remove("hidden");
- subsMessage.innerHTML = "{{ .L.UserProfile.TagAlreadyAdded }}".replace("{}", name);
- return;
- }
-
- tagsPickedList.innerHTML += `
- <div id="tagPicked${name}" class="bg-main-hard text-background-light m-2 flex flex-row">
- <p class="py-1 px-2">${name}</p>
- <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked${name}'));">✘</p>
- <input type="hidden" name="tags_picked" value="${name}">
- </div>
- `;
-}
-
-htmx.on("htmx:configRequest", (e) => {
- const params = e.detail.parameters;
-
- if (Array.isArray(params.tags_picked)) {
- tags_join = params.tags_picked[0] || "";
- for (i = 1; i < params.tags_picked.length; i++) {
- tags_join += ",";
- tags_join += params.tags_picked[i];
- }
- params.tags_picked = tags_join;
- }
-})
</script>
{{ end }} \ No newline at end of file
diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html
index 8da52a6..8d555da 100644
--- a/views/partials/blog-page-like-button.html
+++ b/views/partials/blog-page-like-button.html
@@ -1,5 +1,6 @@
-<button hx-put="/api/v1/like" hx-vals='{"like": {{ not .Liked }}}' hx-target="this" hx-swap="outerHTML" hx-trigger="click"
- class="themed-button themed-button-like flex flex-col shrink-0 w-fit h-fit mr-2 my-auto{{ if .Liked }} active{{ end }}">
- <i class="fas fa-thumbs-up w-8 h-fit py-0.5 content-center text-center"></i>
- <div class="h-fit px-0.5 content-center bg-(--background-medium-color) text-(--main-hard-color)">{{ .LikedCount }}</div>
+<button
+ hx-put="/api/v1/like" hx-vals='{"like": {{ not .Liked }}}' hx-target="this" hx-swap="outerHTML" hx-trigger="click"
+ class="text-sm/[0.9] shrink-0 font-andika text-left px-1 py-0.5 {{ if .Liked }}bg-main-soft hover:bg-main-medium text-background-dark border-inset{{ else }}bg-main-hard hover:bg-main-medium text-background-light border-outset{{ end }} cursor-pointer border-2 border-background-dark">
+ <i class="fas fa-thumbs-up w-3 h-3"></i>
+ <span class="italic ml-0.5 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 b50f419..9bc5a30 100644
--- a/views/partials/catalogue-blog-cards.html
+++ b/views/partials/catalogue-blog-cards.html
@@ -5,7 +5,7 @@
<img onclick="return changeUrl('{{ .ArticleLink }}');" class="w-full h-full aspect-square cursor-pointer object-cover rounded-lg select-none" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp">
</a>
<div class="grow my-0.5 mx-1 flex flex-col justify-center">
- <div class="flex flex-row gap-2 max-w-full">
+ <div class="flex flex-row gap-2 max-w-[100%]">
<a href="{{ .ArticleLink }}" onclick="return changeUrl('{{ .ArticleLink }}');" class="grow text-base cursor-pointer">
<span class="font-extrabold">{{ .Title }}</span>
<span class="font-extrabold select-none text-secondary">//</span>
diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html
index d697c14..c3aa3eb 100644
--- a/views/partials/general-page-body.html
+++ b/views/partials/general-page-body.html
@@ -1 +1,2 @@
+<title>{{ .Title }} // SAYA TODAY</title>
{{ block "body" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/general-page-header.html b/views/partials/general-page-header.html
index e019d3b..a29d40b 100644
--- a/views/partials/general-page-header.html
+++ b/views/partials/general-page-header.html
@@ -1,5 +1,6 @@
-<title>{{ .Title }} // SAYA TODAY</title>
-<div class="flex flex-row mb-2">
- {{ block "header" . }}{{ end }}
- <p class="text-4xl font-andika font-light italic text-shadow-[0_2px_2px_var(--main-soft-color)] text-left my-auto">{{ .Title }}</p>
-</div> \ No newline at end of file
+<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-2">
+ <p class="text-4xl font-andika font-light italic text-shadow-[0_2px_2px_var(--main-soft-color)] text-left mt-auto">{{ .Title }}</p>
+ <p id="published-date" class="grow text-2xl font-andika italic text-secondary text-right mt-auto">{{ .PublishedDate }}</p>
+</div>
+{{ block "header" . }}{{ end }}