68 files changed, 2493 insertions, 1882 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml index d57e872..413f402 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@v4 + uses: actions/checkout@v6 - 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 16e6563..c6e222b 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@v4 + uses: actions/checkout@v6 - name: Generate output.css with tailwindcss uses: ZoeyVid/tailwindcss-update@main @@ -1,4 +1,4 @@ -FROM golang:1.24.6-alpine3.22 AS build-stage +FROM golang:1.25.3-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.1 AS runtime-stage +FROM alpine:3.22.2 AS runtime-stage ENV ENVIRONMENT="" ENV AUTH_SALT="" diff --git a/config/config.go b/config/config.go index d5dab2d..1393dd8 100644 --- a/config/config.go +++ b/config/config.go @@ -62,13 +62,18 @@ 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"` + 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"` } func LoadConfig(path string, config *Config) error { diff --git a/config/config.local.yaml b/config/config.local.yaml index 008fa93..e23c808 100644 --- a/config/config.local.yaml +++ b/config/config.local.yaml @@ -32,7 +32,8 @@ auth: db: type: sqlite3 config: - dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc' + # 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' salt: '123' mail: clientHost: '127.0.0.1:3000' @@ -42,3 +43,5 @@ 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 4c0f425..0bee79d 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' + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' salt: '${AUTH_SALT}' mail: clientHost: '${FQDN}' @@ -42,3 +42,5 @@ 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 003ec05..1c15dae 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' + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' salt: '${AUTH_SALT}' mail: clientHost: '${FQDN}' @@ -42,3 +42,5 @@ mail: username: '${MAIL_USERNAME}' password: '${MAIL_PASSWORD}' salt: '${MAIL_SALT}' + trigger: + onNewPost: "0/3 * * * *" @@ -1,43 +1,43 @@ module github.com/SayaAndy/saya-today-web -go 1.24.6 +go 1.25 require ( github.com/Backblaze/blazer v0.7.2 - github.com/go-playground/validator/v10 v10.27.0 + 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/gofiber/fiber/v2 v2.52.9 - github.com/golang-migrate/migrate/v4 v4.18.3 + github.com/golang-migrate/migrate/v4 v4.19.0 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.41.0 + golang.org/x/crypto v0.43.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/dgraph-io/ristretto/v2 v2.3.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/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-co-op/gocron/v2 v2.17.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect 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.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect + github.com/kr/pretty v0.3.1 // 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.16 // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/valyala/bytebufferpool v1.0.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 + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect ) @@ -4,11 +4,17 @@ 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.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= @@ -21,12 +27,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o 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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +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/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.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/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/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= @@ -36,8 +42,12 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l 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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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/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= @@ -46,46 +56,40 @@ 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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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-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/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/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/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/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/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.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= -github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= +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/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/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= +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= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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= +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= 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/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go index ab66981..c5b3097 100644 --- a/internal/blogtrigger/blogtrigger.go +++ b/internal/blogtrigger/blogtrigger.go @@ -16,7 +16,7 @@ type BlogTriggerScheduler struct { onTrigger func([]*b2.BlogPage) error } -func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, 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) @@ -30,7 +30,7 @@ func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config. bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger} defer bts.s.Start() - bts.s.NewJob(gocron.CronJob("0/5 * * * *", false), gocron.NewTask(func(bts *BlogTriggerScheduler) { + 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())) @@ -49,6 +49,10 @@ func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config. 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 { diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go index 83312dc..6062113 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -9,15 +9,16 @@ import ( ) type Metadata struct { - Title string `yaml:"title"` - ShortDescription string `yaml:"shortDescription"` - ActionDate string `yaml:"actionDate"` - PublishedTime time.Time `yaml:"publishedTime"` - Thumbnail string `yaml:"thumbnail"` - Tags []string `yaml:"tags"` - Geolocation string `yaml:"geolocation"` - Medley string `yaml:"medley"` - MedleyPart int `yaml:"medleyPart"` + Title string `yaml:"title"` + ShortDescription string `yaml:"shortDescription"` + ActionDate string `yaml:"actionDate"` + PublishedTime time.Time `yaml:"publishedTime"` + Thumbnail string `yaml:"thumbnail"` + Tags []string `yaml:"tags"` + Geolocation string `yaml:"geolocation"` + Medley string `yaml:"medley"` + MedleyPart int `yaml:"medleyPart"` + ContentSettings map[string]string `yaml:"contentSettings"` } func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go index e53e67b..30cda1c 100644 --- a/internal/glightbox/block.go +++ b/internal/glightbox/block.go @@ -9,8 +9,9 @@ import ( // GLightboxBlock represents a light gallery block in the AST type GLightboxBlock struct { ast.BaseBlock - Images []GLightboxImage - Location *time.Location + Images []GLightboxImage + Location *time.Location + Namespace string } type GLightboxImage struct { diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go index 7022793..ac34880 100644 --- a/internal/glightbox/html_renderer.go +++ b/internal/glightbox/html_renderer.go @@ -68,7 +68,8 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, var elements []string for i, img := range gallery.Images { - imageUrlSegments := strings.Split(img.URL, ".") + fullImageUrl := gallery.Namespace + img.URL + imageUrlSegments := strings.Split(fullImageUrl, ".") imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") imageUrlParts := strings.Split(imageUrlWithoutExt, "/") imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-") @@ -122,7 +123,7 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, </picture> <span class="grid-tooltip-text"><p>%s</p></span> <span class="grid-item-index">%d</span> - </a>`, img.URL, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1)) + </a>`, fullImageUrl, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1)) if glightboxDescId != "" { elements = append(elements, fmt.Sprintf(` @@ -132,44 +133,43 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, } } - w.WriteString(fmt.Sprintf(` -<div class="justify-content-center display-block"> + w.WriteString(strings.ReplaceAll(` +<div class="items-center flex flex-col"> <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 mx-auto"> - <div class="grid-sizer grid-sizer-%s"></div> - %s + <div class="grid masonry-grid-{id}"> + <div class="grid-sizer grid-sizer-{id}"></div> + `+strings.Join(elements, "\n")+` </div> <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> -</div>`, galleryID, galleryID, strings.Join(elements, "\n"))) +</div> - w.WriteString(fmt.Sprintf(` <script> - var msnry_%s = new Masonry('.masonry-grid-%s', { - itemSelector: '.grid-item-%s', - columnWidth: '.grid-sizer-%s', - percentPosition: false, - fitWidth: true + var pckry_{id} = new Packery('.masonry-grid-{id}', { + itemSelector: '.grid-item-{id}', + columnWidth: '.grid-sizer-{id}', + percentPosition: false }); - var imgLoad_%s_timer; - var imgLoad_%s = imagesLoaded('.masonry-grid-%s'); + var imgLoad_{id}_timer; + var imgLoad_{id} = imagesLoaded('.masonry-grid-{id}'); - function initMasonryLayout_%s(tm) { - clearTimeout(imgLoad_%s_timer); - imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100); + function initMasonryLayout_{id}(tm) { + clearTimeout(imgLoad_{id}_timer); + imgLoad_{id}_timer = setTimeout(() => pckry_{id}.layout(), 100); } - imgLoad_%s.on('progress', initMasonryLayout_%s); + imgLoad_{id}.on('progress', initMasonryLayout_{id}); - window.addEventListener('resize', initMasonryLayout_%s); + window.addEventListener('resize', initMasonryLayout_{id}); document.addEventListener('popout', (e) => { - window.removeEventListener('resize', initMasonryLayout_%s); - imgLoad_%s.off('progress', initMasonryLayout_%s); - initMasonryLayout_%s = null; - imgLoad_%s = null; + window.removeEventListener('resize', initMasonryLayout_{id}); + imgLoad_{id}.off('progress', initMasonryLayout_{id}); + initMasonryLayout_{id} = null; + imgLoad_{id} = null; }, {once: true}); -</script>`, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID)) +</script> +`, "{id}", galleryID)) } return ast.WalkContinue, nil diff --git a/internal/glightbox/parser.go b/internal/glightbox/parser.go index 6780090..b376034 100644 --- a/internal/glightbox/parser.go +++ b/internal/glightbox/parser.go @@ -29,7 +29,7 @@ func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Co return nil, parser.NoChildren } - r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`) + r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)(:([A-Za-z0-9\+\-/]+))?\}$`) trimmed := bytes.TrimSpace(line) parts := r.FindSubmatch(trimmed) @@ -44,7 +44,12 @@ func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Co return nil, parser.NoChildren } - return &GLightboxBlock{Location: loc}, parser.NoChildren + ns := "" + if len(parts) >= 4 { + ns = string(parts[3]) + } + + return &GLightboxBlock{Location: loc, Namespace: ns}, parser.NoChildren } func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index c146548..d6dbd17 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -152,12 +152,20 @@ 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() @@ -170,12 +178,19 @@ func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err erro 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 { tx.Rollback() return "", "", fmt.Errorf("failed to query user-email settings in db: %s", err) } - defer tx.Commit() defer rows.Close() if !rows.Next() { @@ -302,18 +317,22 @@ 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]) @@ -326,6 +345,14 @@ func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionT 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 @@ -333,7 +360,6 @@ func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionT tx.Rollback() return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err) } - defer tx.Commit() defer rows.Close() if !rows.Next() { @@ -361,6 +387,8 @@ func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, 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 { @@ -376,13 +404,16 @@ func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, 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 } @@ -393,9 +424,11 @@ func (m *Mailer) NewPost(post *b2.BlogPage) error { 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) } @@ -419,21 +452,11 @@ rowLoop: continue } - email, lang, err := m.GetInfo(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 - } - if tagsString == "_all" { usersToSend = append(usersToSend, struct { userId []byte email string - }{userId, email}) + }{userId, ""}) continue } @@ -443,7 +466,7 @@ rowLoop: usersToSend = append(usersToSend, struct { userId []byte email string - }{userId, email}) + }{userId, ""}) continue rowLoop } } @@ -451,9 +474,27 @@ 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) diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go deleted file mode 100644 index 5b1d14d..0000000 --- a/internal/router/api-v1-blog-search.go +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 83c783c..0000000 --- a/internal/router/api-v1-email-is-in-verification.go +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 9372eb3..0000000 --- a/internal/router/api-v1-email-send-verification-code.go +++ /dev/null @@ -1,98 +0,0 @@ -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(Mailer.GetHash(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 deleted file mode 100644 index 7fbfdec..0000000 --- a/internal/router/api-v1-email-verify.go +++ /dev/null @@ -1,52 +0,0 @@ -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+"\n\n"+l[lang].UserProfile.RefreshPage, 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 deleted file mode 100644 index b5567b7..0000000 --- a/internal/router/api-v1-general-page-body.go +++ /dev/null @@ -1,243 +0,0 @@ -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/blogtrigger" - "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 -var BlogTrigger *blogtrigger.BlogTriggerScheduler - -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])) - } - - tagsArray, err := getTags(b2Client, lang) - if err != nil { - slog.Warn("failed to get the available tags", slog.String("error", err.Error())) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags") - } - - 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(Mailer.GetHash(c.IP())) - if err != nil { - slog.Error("get info from mailer about a client", slog.String("error", err.Error())) - } - - tagsArray, err := getTags(b2Client, lang) - if err != nil { - slog.Warn("failed to get the available tags", slog.String("error", err.Error())) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags") - } - - subscriptionType, tags, err := Mailer.GetSubscriptions(c.IP()) - if err != nil { - slog.Warn("failed to get the user subscriptions", slog.String("error", err.Error())) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to get the user subscriptions") - } - - switch subscriptionType { - case mailer.None: - values["TagsPicked"] = "none" - case mailer.All: - values["TagsPicked"] = "all" - case mailer.Specific: - values["TagsPicked"] = "specific" - } - - values["TagsPickedList"] = tags - - values["Email"] = email - values["EmailCode"] = c.Query("email_code") - values["ExistingTags"] = tagsArray - - 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 -} - -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/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go deleted file mode 100644 index b408187..0000000 --- a/internal/router/api-v1-general-page-bottom-embeds.go +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 99d899a..0000000 --- a/internal/router/api-v1-general-page-footer.go +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 768417d..0000000 --- a/internal/router/api-v1-general-page-header.go +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index f3e480b..0000000 --- a/internal/router/api-v1-general-page-top-embeds.go +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index c8b4362..0000000 --- a/internal/router/api-v1-general-page.go +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 323c457..0000000 --- a/internal/router/api-v1-like.go +++ /dev/null @@ -1,122 +0,0 @@ -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-subs.go b/internal/router/api-v1-subs.go deleted file mode 100644 index 72f16e0..0000000 --- a/internal/router/api-v1-subs.go +++ /dev/null @@ -1,67 +0,0 @@ -package router - -import ( - "net/url" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/mailer" - "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_Subs_Put(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: - 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: - return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.SubscribeInvalidType, map[string]string{}) - } - - specificTags := c.FormValue("tags_picked") - if err = Mailer.Subscribe(Mailer.GetHash(id), subscriptionTypeEnum, specificTags); err != nil { - return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.FailedToSubscribe, map[string]string{}) - } - - return api_v1_email_sendStatusHtml(c, "subs-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.SubscribedSuccessfully, map[string]string{}) - } -} diff --git a/internal/router/api-v1-tz.go b/internal/router/api-v1-tz.go deleted file mode 100644 index 4e4e462..0000000 --- a/internal/router/api-v1-tz.go +++ /dev/null @@ -1,37 +0,0 @@ -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 new file mode 100644 index 0000000..4a94a48 --- /dev/null +++ b/internal/router/basic-handler.go @@ -0,0 +1,68 @@ +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) AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { + return map[string]string{ + "robots": "noindex,nofollow", + }, 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 d607cdd..7c28b9e 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,6 +62,7 @@ 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) } @@ -81,8 +82,11 @@ 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), @@ -99,18 +103,28 @@ 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) } - return tx.Commit() + 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 } 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 new file mode 100644 index 0000000..a0316e2 --- /dev/null +++ b/internal/router/handlers/api-v1-blog-search.go @@ -0,0 +1,126 @@ +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 new file mode 100644 index 0000000..4f05f48 --- /dev/null +++ b/internal/router/handlers/api-v1-email-is-in-verification.go @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..20c5fb6 --- /dev/null +++ b/internal/router/handlers/api-v1-email-send-verification-code.go @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..c9ec969 --- /dev/null +++ b/internal/router/handlers/api-v1-email-verify.go @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..e9c176e --- /dev/null +++ b/internal/router/handlers/api-v1-like-get.go @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..ea4ef4b --- /dev/null +++ b/internal/router/handlers/api-v1-like-put.go @@ -0,0 +1,74 @@ +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 new file mode 100644 index 0000000..144abe5 --- /dev/null +++ b/internal/router/handlers/api-v1-subs-put.go @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..8b08113 --- /dev/null +++ b/internal/router/handlers/api-v1-tz-get.go @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..97de089 --- /dev/null +++ b/internal/router/handlers/lang-blog-title.go @@ -0,0 +1,126 @@ +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) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { + metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") + if err != nil { + return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err) + } + + return map[string]string{ + "og:title": metadata.Title, + "og:description": fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate), + "og:image": fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", metadata.Thumbnail), + "twitter:card": "summary_large_image", + }, nil +} + +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 new file mode 100644 index 0000000..e4c20c6 --- /dev/null +++ b/internal/router/handlers/lang-blog.go @@ -0,0 +1,112 @@ +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 new file mode 100644 index 0000000..7df6104 --- /dev/null +++ b/internal/router/handlers/lang-map.go @@ -0,0 +1,101 @@ +package handlers + +import ( + "fmt" + "log/slog" + "slices" + "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 + "/") + slices.SortFunc(pages, func(a *b2.BlogPage, b *b2.BlogPage) int { + return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime) + }) + 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 { + Index int `json:"Index"` + 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 i, 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{ + Index: i, + 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 new file mode 100644 index 0000000..4fbd244 --- /dev/null +++ b/internal/router/handlers/lang-user-unsubscribe.go @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..c22bcac --- /dev/null +++ b/internal/router/handlers/lang-user.go @@ -0,0 +1,85 @@ +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) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { + return map[string]string{ + "og:title": supplements.Localization[lang].BlogSearch.Header, + "og:description": supplements.Localization[lang].BlogSearch.Description, + }, nil +} + +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 new file mode 100644 index 0000000..74166ae --- /dev/null +++ b/internal/router/handlers/lang.go @@ -0,0 +1,64 @@ +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) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { + return map[string]string{ + "og:title": supplements.Localization[lang].HomePage.Header, + "og:description": supplements.Localization[lang].HomePage.HomePageDescription, + "og:image": "https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/" + fmt.Sprintf("otter-%d.gif", rand.Int()%3+1), + }, nil +} + +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 new file mode 100644 index 0000000..9f15782 --- /dev/null +++ b/internal/router/handlers/root.go @@ -0,0 +1,54 @@ +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) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { + return map[string]string{ + "og:title": "Saya Blog", + "og:description": "The blog of a 25 years old, travelling God knows where, proud to be not mainstream // Личный блог 25-летки, странствующего по ебеням, зато не мейнстрим", + }, nil +} + +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 deleted file mode 100644 index 619b05b..0000000 --- a/internal/router/lang-map.go +++ /dev/null @@ -1,88 +0,0 @@ -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/lang-user-unsubscribe.go b/internal/router/lang-user-unsubscribe.go deleted file mode 100644 index a6ca89f..0000000 --- a/internal/router/lang-user-unsubscribe.go +++ /dev/null @@ -1,69 +0,0 @@ -package router - -import ( - "fmt" - "html/template" - "log/slog" - - "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("unsubscribe-page", "views/pages/unsubscribe-page.html")) -} - -func Lang_User_Unsubscribe(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) 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: - var statusEmoji, statusText, statusColor string - var status int - - unsubscribeCode := c.FormValue("code") - if unsubscribeCode == "" { - statusColor = "0, 0, 255" - statusEmoji = "(╭ರ_•́)" - statusText = l[lang].UnsubscribePage.UnsetCode - status = fiber.ErrBadRequest.Code - } else if clientError, serverError := Mailer.Unsubscribe(unsubscribeCode); clientError != nil { - statusColor = "255, 0, 0" - statusEmoji = "(͠≖~≖ ͡ )" - statusText = l[lang].UnsubscribePage.InvalidCode - status = fiber.ErrBadRequest.Code - } else if serverError != nil { - statusColor = "255, 128, 0" - statusEmoji = "( ˶°ㅁ°) !!" - statusText = l[lang].UnsubscribePage.OnServerError - status = fiber.ErrInternalServerError.Code - } else { - statusColor = "0, 255, 0," - statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡" - statusText = l[lang].UnsubscribePage.Success - status = fiber.StatusOK - } - - content, err := tm.Render("unsubscribe-page", fiber.Map{ - "Lang": lang, - "L": l[lang], - "StatusEmoji": statusEmoji, - "StatusText": statusText, - "StatusColor": template.HTML(statusColor), - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/user/unsubscribe"), 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 deleted file mode 100644 index 01e4792..0000000 --- a/internal/router/page-cache.go +++ /dev/null @@ -1,5 +0,0 @@ -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 new file mode 100644 index 0000000..c8aef08 --- /dev/null +++ b/internal/router/path-matcher.go @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..f28add2 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,572 @@ +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) + AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta map[string]string, 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) + } + + valueMap := fiber.Map{ + "L": r.supplements.Localization[lang], + "Lang": lang, + "QueryString": queryString, + } + + var err error + if valueMap["Meta"], err = route.AddMeta(c, r.supplements, lang, valueMap); err != nil { + slog.Warn("failed to receive meta", slog.String("path", path), slog.String("error", err.Error())) + } + + content, err := r.supplements.TemplateManager.Render("general-page", valueMap) + 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 deleted file mode 100644 index e8095ee..0000000 --- a/internal/router/tm.go +++ /dev/null @@ -1,18 +0,0 @@ -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 bafdee5..b23e2e8 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -32,6 +32,9 @@ var templateFuncMap = template.FuncMap{ return items }, "replace": strings.ReplaceAll, + "fdiv": func(a, b int) float64 { + return float64(a) / float64(b) + }, } func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { @@ -39,10 +42,18 @@ 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, @@ -57,12 +68,16 @@ 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 is not found", name) + return nil, fmt.Errorf("template %s 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() @@ -73,20 +88,27 @@ 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, tmpl.Main, data) + err = tempTmpl.ExecuteTemplate(&buf, mainTmpl, data) return buf.Bytes(), err } func (tm *TemplateManager) Add(name string, files ...string) error { + tmpl := template.New(name).Funcs(templateFuncMap) + if len(files) == 0 { - return fmt.Errorf("you can't add template without any files") + tm.templates[name] = templateManagerRender{ + Main: "", + Tmpl: tmpl, + } + return nil } - 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) @@ -96,5 +118,6 @@ 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..e165866 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -1,6 +1,7 @@ TagsLabel: 'Tags:' BlogSearch: Header: 'Blog Search' + Description: "The Saya Blog posts' list" TagsHeader: 'Tags' OrderByHeader: 'Order by...' TitleOrdered: 'Title' @@ -9,6 +10,7 @@ BlogSearch: ChooseAllTags: 'Choose All' GlobalMap: Header: 'Global Map' + Description: "Global map with all the tags of places I've been to" HomePage: Header: 'Home Page' Contacts: 'Contacts' diff --git a/locale/localization.go b/locale/localization.go index c6467c4..a4f36f1 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -20,6 +20,7 @@ type LocaleConfig struct { type BlogSearchConfig struct { Header string `yaml:"Header" json:"Header"` + Description string `yaml:"Description" json:"Description"` TagsHeader string `yaml:"TagsHeader" json:"TagsHeader"` OrderByHeader string `yaml:"OrderByHeader" json:"OrderByHeader"` TitleOrdered string `yaml:"TitleOrdered" json:"TitleOrdered"` @@ -29,7 +30,8 @@ type BlogSearchConfig struct { } type GlobalMapConfig struct { - Header string `yaml:"Header" json:"Header"` + Header string `yaml:"Header" json:"Header"` + Description string `yaml:"Description" json:"Description"` } type HomePageConfig struct { diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index 5df82da..e65848a 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -1,6 +1,7 @@ TagsLabel: 'Тэги:' BlogSearch: Header: 'Поиск по блогу' + Description: 'Каталог записей Saya Blog' TagsHeader: 'Тэги' OrderByHeader: 'Упорядочить по...' TitleOrdered: 'названию' @@ -9,6 +10,7 @@ BlogSearch: ChooseAllTags: 'Выбрать все' GlobalMap: Header: 'Глобальная карта' + Description: 'Глобальная карта с метками мест, где я побывал' HomePage: Header: 'Домашняя страница' Contacts: 'Контакты' @@ -1,8 +1,6 @@ package main import ( - "database/sql" - "errors" "flag" "log/slog" "os" @@ -10,52 +8,15 @@ import ( "syscall" "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/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 ( - 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) + configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)") ) func main() { @@ -72,145 +33,39 @@ func main() { slog.SetLogLoggerLevel(cfg.LogLevel) slog.Info("starting sayana-web server...") - db, err := sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN) + app, err := router.NewRouter(cfg) if err != nil { - slog.Error("fail to initialize db", slog.String("error", err.Error())) + slog.Error("fail to initialize router app", slog.String("error", err.Error())) os.Exit(1) } - driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) - if err != nil { - slog.Error("fail to initialize driver for migrating db", slog.String("error", err.Error())) - os.Exit(1) - } - - m, err := migrate.NewWithDatabaseInstance( - "file://migrations", - cfg.Auth.Db.Type, driver) - if err != nil { - slog.Error("fail to initialize migration client", slog.String("error", err.Error())) - os.Exit(1) - } - - if err = m.Up(); err != nil && err == errors.New("no change") { - slog.Error("fail to apply migrations", slog.String("error", err.Error())) + if err = app.InitRoutes(); err != nil { + slog.Error("fail to initialize routes inside router app", 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) - } - - 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) - } - - router.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(b2Client, cfg.AvailableLanguages, func(bp []*b2.BlogPage) error { - for _, post := range bp { - if err := router.Mailer.NewPost(post); err != nil { - return err - } - } - return nil - }) - if err != nil { - slog.Error("fail to initialize blog trigger", 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/user/unsubscribe", router.Lang_User_Unsubscribe(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.Put("/api/v1/subs", router.Api_V1_Subs_Put(localization)) - - app.Static("/", "./static") + routerShutdown := make(chan struct{}, 1) go func() { if err := app.Listen(":3000"); err != nil { slog.Error("error while running fiber server", slog.String("error", err.Error())) - panic(err) + if nestedErr := app.Close(); nestedErr != nil { + slog.Error("error while shutting down fiber server", slog.String("error", err.Error())) + } + <-routerShutdown } }() sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - slog.Info("gracefully shutting down...") - if err = app.Shutdown(); err != nil { - slog.Error("fail to shutdown fiber server", slog.String("error", err.Error())) - } - if err = router.CCache.Close(); err != nil { - slog.Error("fail to dump cache", slog.String("error", err.Error())) - } - if err = db.Close(); err != nil { - slog.Error("fail to close db connection", slog.String("error", err.Error())) + 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...") } - router.PCache.Close() - db.Close() } diff --git a/static/input.css b/static/input.css index 739caa3..40082c8 100644 --- a/static/input.css +++ b/static/input.css @@ -21,6 +21,11 @@ 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; @@ -46,14 +51,21 @@ html { } [data-theme="olive"] { - --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; + --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); --sidebar-text-color: var(--background-dark-color); - --sidebar-stroke-color: var(--main-hard-color); + --sidebar-stroke-color: var(--main-dark-color-hard); --secondary-color: var(--main-soft-color); --leaflet-text-color: var(--main-hard-color); --leaflet-link-color: var(--main-medium-color); @@ -68,21 +80,27 @@ html { } [data-theme="lettuce"] { - --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; + --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); --sidebar-text-color: var(--main-hard-color); - --sidebar-stroke-color: var(--background-dark-color); + --sidebar-stroke-color: var(--main-dark-color-hard); --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("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; + --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; --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; @@ -90,14 +108,20 @@ html { } [data-theme="night"] { - --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; + --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); --sidebar-text-color: var(--main-hard-color); - --sidebar-stroke-color: var(--background-light-color); + --sidebar-stroke-color: var(--main-light-color-soft); --secondary-color: var(--main-soft-color); --leaflet-text-color: var(--background-dark-color); --leaflet-link-color: var(--background-dark-color); @@ -112,14 +136,20 @@ html { } [data-theme="ram"] { - --main-hard-color: #fefad8; - --main-medium-color: #f6e4c5; - --main-soft-color: #e8d4b8; - --background-dark-color: #413b34; - --background-medium-color: #62584f; - --background-light-color: #6d6258; + --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); --sidebar-text-color: var(--main-hard-color); - --sidebar-stroke-color: var(--background-light-color); + --sidebar-stroke-color: var(--main-light-color-soft); --secondary-color: var(--main-soft-color); --leaflet-text-color: var(--background-dark-color); --leaflet-link-color: var(--background-dark-color); @@ -134,14 +164,20 @@ html { } [data-theme="lilac"] { - --main-hard-color: #343b58; - --main-medium-color: #414868; - --main-soft-color: #6c6e75; - --background-dark-color: #c3caff; - --background-medium-color: #d1d6ff; - --background-light-color: #e3e6ff; + --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); --sidebar-text-color: var(--main-hard-color); - --sidebar-stroke-color: var(--background-light-color); + --sidebar-stroke-color: var(--main-light-color-hard); --secondary-color: var(--main-medium-color); --leaflet-text-color: var(--main-hard-color); --leaflet-link-color: var(--main-medium-color); @@ -227,11 +263,11 @@ form.crossable.htmx-request input { } .text-sidebar { - color: var(--sidebar-text-color); -} - -.stroke-sidebar { - border-color: var(--sidebar-stroke-color); + 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)); } .text-main-hard { @@ -260,22 +296,22 @@ form.crossable.htmx-request input { .bg-main-hard { background-color: var(--main-hard-color); - background-image: url(data:image/png;base64,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); + background-image: var(--noise-image); } .bg-main-medium { background-color: var(--main-medium-color); - background-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==); + background-image: var(--noise-image); } .bg-main-soft { background-color: var(--main-soft-color); - background-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==); + background-image: var(--noise-image); } .bg-background-dark { background-color: var(--background-dark-color); - background-image: url(data:image/png;base64,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); + background-image: var(--noise-image); } .bg-background-medium { @@ -285,7 +321,7 @@ form.crossable.htmx-request input { .bg-background-light { background-color: var(--background-light-color); - background-image: url(data:image/png;base64,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); + background-image: var(--noise-image); } .border-main-hard { @@ -410,6 +446,57 @@ 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; @@ -468,15 +555,11 @@ form.crossable.htmx-request input { } .grid-item { - position: relative; - display: inline-block; width: 10rem; } .grid-sizer { - position: relative; - display: inline-block; - width: 10.1rem; + width: 10.5rem; } .grid-item-index { @@ -508,7 +591,7 @@ form.crossable.htmx-request input { } .grid-sizer { - width: 12.1rem; + width: 12.5rem; } .grid-item-2x { @@ -517,14 +600,10 @@ form.crossable.htmx-request input { } @container main (width >= 76rem) { - .grid-item { + .grid-item, .grid-sizer { width: 18rem; } - .grid-sizer { - width: 18.1rem; - } - .grid-item-2x { width: 36rem; } @@ -536,7 +615,7 @@ form.crossable.htmx-request input { } .grid-sizer { - width: 24.1rem; + width: 24.5rem; } .grid-item-2x { diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index a7281e8..3f5bcfd 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -3,6 +3,9 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + {{- range $k, $v := .Meta }} + <meta name="{{ $k }}" content="{{ $v }}"> + {{- end }} <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css"> <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/glightbox/3.3.0/css/glightbox.min.css"> @@ -137,6 +140,7 @@ document.body.append(frame); } break; + case "lilac": break; } } @@ -171,24 +175,29 @@ <div class="grow bg-split-left"></div> - <div class="flex flex-col [@media(max-height:32rem)]:flex-row"> - <div id="sidebar" class="bg-sidebar flex sticky flex-col z-20 w-dvw 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-dvh 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> + <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> </a> - <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 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> - <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 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> - <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 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> - <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> + <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> </a> </div> <div class="theme-wheel" id="theme-wheel"> @@ -234,10 +243,10 @@ </script> </div> - <div class="bg-background-dark @container/main flex z-10 w-dvw 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-dvh shadow-[0_0_1rem_black]"> + <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 border-r-2 border-dashed border-main-hard"></div> - <div class="max-h-100% flex flex-col grow bg-background-dark relative pt-4 ml-8 pr-4"> + <div class="max-h-full max-w-full 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> @@ -245,7 +254,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="flex flex-col grow overflow-y-auto" + <div id="general-page-body" class="relative 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> @@ -267,7 +276,40 @@ <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/masonry-layout/4.2.2/masonry.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> <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"> @@ -276,7 +318,7 @@ <script> const themeWheel = document.getElementById('theme-wheel'); - const themeButton = document.getElementById('sidebar-theme-button'); + const themeButton = document.getElementById('toolbar-theme-button'); const themeIcons = document.querySelectorAll('.theme-icon'); const themeWheelRadius = convertRemToPixels(6); @@ -392,7 +434,7 @@ themeWheel.addEventListener('mouseleave', hideThemeWheel); document.addEventListener('click', (e) => { - if (!themeWheel.contains(e.target) && e.target !== themeButton) { + if (!themeWheel.contains(e.target) && e.target !== themeButton && !e.target.classList.contains("toolbar-theme-button-content")) { hideThemeWheel(); } }); diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 8e91798..d2c74ae 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -1,61 +1,68 @@ {{ define "body" }} -<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 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> - {{- 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> + </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> </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> + </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> <div class="w-[0.5dvh] border-r-2 border-dotted border-main-hard bg-background-dark"></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 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> + </div> <script> diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index 7170ad8..c81406f 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -1,9 +1,16 @@ {{ define "body" }} -<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4 overflow-y-auto"> +<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"> <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="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"> + <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"> {{ .ParsedMarkdown }} {{- if .MapLocationX }} <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto"> @@ -14,9 +21,9 @@ function initLocationMap(id, x, y, relativeErrorMeters = 0) { map = L.map(id).setView([x, y], 8); - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 18, - attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' + 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: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }).addTo(map); if (relativeErrorMeters != 0) { @@ -56,6 +63,6 @@ </div> {{ end }} -{{ define "footer" }} +{{ define "header" }} <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 306f442..4b07bd2 100644 --- a/views/pages/global-map.html +++ b/views/pages/global-map.html @@ -3,6 +3,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="og:title" content="{{ .L.GlobalMap.Header }}"> + <meta name="og:description" content="{{ .L.GlobalMap.Description }}"> <title>{{ .L.GlobalMap.Header }} // SAYA TODAY</title> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> {{ block "header" . }}{{ end }} @@ -42,23 +44,25 @@ } </script> - <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> + <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> </a> - <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 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> - <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 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> - <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 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> - <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> + <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> </a> </div> <div class="theme-wheel" id="theme-wheel"> @@ -79,7 +83,7 @@ <script> const themeWheel = document.getElementById('theme-wheel'); - const themeButton = document.getElementById('theme-button'); + const themeButton = document.getElementById('toolbar-theme-button'); const themeIcons = document.querySelectorAll('.theme-icon'); const themeWheelRadius = convertRemToPixels(6); @@ -195,7 +199,7 @@ themeWheel.addEventListener('mouseleave', hideThemeWheel); document.addEventListener('click', (e) => { - if (!themeWheel.contains(e.target) && e.target !== themeButton) { + if (!themeWheel.contains(e.target) && e.target !== themeButton && !e.target.classList.contains("toolbar-theme-button-content")) { hideThemeWheel(); } }); @@ -228,9 +232,9 @@ function initLocationMap() { map = L.map('map-container').setView([{{ .MapLocationLat }}, {{ .MapLocationLong }}], 4); - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 18, - attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', + 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: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', zoomControl: false }).addTo(map); @@ -241,35 +245,32 @@ map.addLayer(markers); } - function stringToColor(str) { + function markerToHsl(str, newCoef) { let hash = 0; - for (let i = 0; i < str.length; i++) { + for (let i = 0; i < str.length / 2; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = str.charCodeAt(str.length - i - 1) + ((hash << 5) - hash); + hash %= 360; } - let color = '#'; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xFF; - color += ('00' + value.toString(16)).substr(-2); - } - - return color; + return [hash, (20 + newCoef * 80).toFixed(0), (20 + newCoef * 75).toFixed(0)]; } - function mapAddMarker(x, y, relativeErrorMeters = 0, title = "", link = "", thumbnail = "") { - const titleColor = stringToColor(title); + function mapAddMarker(x, y, relativeErrorMeters = 0, title = "", link = "", thumbnail = "", newCoef = 1) { + const [h, s, l] = markerToHsl(title, newCoef); + const color = `hsl(${h} ${s}% ${l}%)`; if (relativeErrorMeters != 0) { var circle = L.circle([x, y], { - color: titleColor, - fillColor: titleColor, + color: color, + fillColor: color, fillOpacity: 0.15, radius: relativeErrorMeters }).addTo(map); } var marker = L.marker([x, y], { - icon: createCustomIcon(titleColor), + icon: createCustomIcon(color), title: title, }).bindPopup(` <div class="flex flex-row justify-center justify-items-center"> @@ -283,7 +284,7 @@ document.addEventListener('DOMContentLoaded', () => { initLocationMap(); {{- range .MapMarkers }} - mapAddMarker({{ .Lat }}, {{ .Long }}, {{ .AccuracyMeters }}, {{ .Title }}, {{ .PageLink }}, {{ .Thumbnail }}); + mapAddMarker({{ .Lat }}, {{ .Long }}, {{ .AccuracyMeters }}, {{ .Title }}, {{ .PageLink }}, {{ .Thumbnail }}, {{ fdiv .Index (len $.MapMarkers) }}); {{- end }} }); diff --git a/views/pages/home-page.html b/views/pages/home-page.html index 11d0f7b..6d397fa 100644 --- a/views/pages/home-page.html +++ b/views/pages/home-page.html @@ -1,20 +1,21 @@ {{ define "body" }} -<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="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="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/[2] sm:text-4xl/[2] lg:text-5xl/[2] tracking-widest uppercase w-full font-andika italic text-left + <p class="text-3xl/loose sm:text-4xl/loose lg:text-5xl/loose tracking-widest uppercase w-full font-andika italic text-left border-l-4 border-solid border-(--background-dark-color) - bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Hymn1 }}</p> + bg-linear-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/[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> + <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> </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-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.DidYouKnowThat }}</p> + bg-linear-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> @@ -28,7 +29,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-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Contacts }}</p> + bg-linear-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> @@ -103,7 +104,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-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.SidebarDescription }}</p> + bg-linear-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 5a9d229..775c18e 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-[100%]"> +<div class="flex flex-col justify-center text-3xl h-full"> {{- 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 index 3876818..c1db9d6 100644 --- a/views/pages/unsubscribe-page.html +++ b/views/pages/unsubscribe-page.html @@ -3,6 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="robots" content="noindex,nofollow"> <title>{{ .L.UnsubscribePage.Header }} // SAYA TODAY</title> <style> html { diff --git a/views/pages/user-page.html b/views/pages/user-page.html index 7d39825..fda7e66 100644 --- a/views/pages/user-page.html +++ b/views/pages/user-page.html @@ -1,5 +1,6 @@ {{ define "body" }} -<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4"> +<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" 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"> diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html index 8d555da..8da52a6 100644 --- a/views/partials/blog-page-like-button.html +++ b/views/partials/blog-page-like-button.html @@ -1,6 +1,5 @@ -<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 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>
\ No newline at end of file diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html index c3aa3eb..d697c14 100644 --- a/views/partials/general-page-body.html +++ b/views/partials/general-page-body.html @@ -1,2 +1 @@ -<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 a29d40b..e019d3b 100644 --- a/views/partials/general-page-header.html +++ b/views/partials/general-page-header.html @@ -1,6 +1,5 @@ -<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 }} +<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 |