80 files changed, 5264 insertions, 1410 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml index 53e307e..d57e872 100644 --- a/.github/workflows/build-and-deploy-prod.yml +++ b/.github/workflows/build-and-deploy-prod.yml @@ -75,4 +75,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} + ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt="${{ secrets.AUTH_SALT }}" -e saya_today_web_b2_key_id="${{ secrets.B2_KEY_ID }}" -e saya_today_web_b2_application_key="${{ secrets.B2_APPLICATION_KEY }}" -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} -e saya_today_web_mail_salt="${{ secrets.MAIL_SALT }}" -e saya_today_web_mail_host="${{ secrets.MAIL_HOST }}" -e saya_today_web_mail_address="${{ secrets.MAIL_ADDRESS }}" -e saya_today_web_mail_username="${{ secrets.MAIL_USERNAME }}" -e saya_today_web_mail_password="${{ secrets.MAIL_PASSWORD }}" diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml index 104d9ac..16e6563 100644 --- a/.github/workflows/build-and-deploy-stage.yml +++ b/.github/workflows/build-and-deploy-stage.yml @@ -86,4 +86,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} + ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt="${{ secrets.AUTH_SALT }}" -e saya_today_web_b2_key_id="${{ secrets.B2_KEY_ID }}" -e saya_today_web_b2_application_key="${{ secrets.B2_APPLICATION_KEY }}" -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} -e saya_today_web_mail_salt="${{ secrets.MAIL_SALT }}" -e saya_today_web_mail_host="${{ secrets.MAIL_HOST }}" -e saya_today_web_mail_address="${{ secrets.MAIL_ADDRESS }}" -e saya_today_web_mail_username="${{ secrets.MAIL_USERNAME }}" -e saya_today_web_mail_password="${{ secrets.MAIL_PASSWORD }}" @@ -1,22 +1,26 @@ -FROM golang:1.24.5-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 WORKDIR /builddir COPY . . -RUN go build -o sayana-web . +RUN CGO_ENABLED=1 go build -o sayana-web . -FROM alpine:3.22.1 AS runtime-stage +FROM alpine:3.22.2 AS runtime-stage ENV ENVIRONMENT="" +ENV AUTH_SALT="" ENV B2_KEY_ID="" ENV B2_APPLICATION_KEY="" WORKDIR /app COPY --from=build-stage /builddir/sayana-web /app/sayana-web +COPY --from=build-stage /builddir/migrations /app/migrations COPY --from=build-stage /builddir/static /app/static COPY --from=build-stage /builddir/views /app/views COPY --from=build-stage /builddir/locale/*.yaml /app/locale/ COPY --from=build-stage /builddir/config/config*.yaml /app/config/ -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata sqlite ENTRYPOINT /app/sayana-web -c /app/config/config.${ENVIRONMENT}.yaml diff --git a/config/config.go b/config/config.go index 8c0321e..1393dd8 100644 --- a/config/config.go +++ b/config/config.go @@ -11,8 +11,11 @@ import ( type Config struct { LogLevel slog.Level `json:"LogLevel" yaml:"logLevel" validate:"required"` BlogPages BlogPagesConfig `json:"BlogPages" yaml:"blogPages" validate:"required"` + FactGiver FactGiverConfig `json:"FactGiver" yaml:"factGiver" validate:"required"` LocalePath string `json:"LocalePath" yaml:"localePath" validate:"required,filepath"` AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"` + Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"` + Mail MailConfig `json:"Mail" yaml:"mail" validate:"required"` } type BlogPagesConfig struct { @@ -32,6 +35,11 @@ type B2Config struct { ApplicationKey string `json:"ApplicationKey" yaml:"applicationKey"` } +type FactGiverConfig struct { + Storage StorageConfig `json:"Storage" yaml:"storage" validate:"required"` + FactsFileName string `json:"FactsFileName" yaml:"factsFileName" validate:"required"` +} + type AvailableLanguageConfig struct { Name string `json:"Name" yaml:"name" validate:"required"` Alt string `json:"Alt" yaml:"alt"` @@ -39,6 +47,35 @@ type AvailableLanguageConfig struct { LocFile string `json:"LocFile" yaml:"locFile" validate:"required,filepath"` } +type AuthConfig struct { + Salt string `json:"Salt" yaml:"salt" validate:"required"` + Db DbConfig `json:"Db" yaml:"db" validate:"required"` +} + +type DbConfig struct { + Type string `json:"Type" yaml:"type" validate:"required,oneof=sqlite3"` + Cfg Sqlite3Config `json:"Config" yaml:"config"` +} + +type Sqlite3Config struct { + DSN string `json:"DSN" yaml:"dsn" validate:"required"` +} + +type MailConfig struct { + ClientHost string `json:"ClientHost" yaml:"clientHost" validate:"required"` + MailHost string `json:"MailHost" yaml:"mailHost" validate:"required"` + PublicName string `json:"PublicName" yaml:"publicName" validate:"required"` + MailAddress string `json:"MailAddress" yaml:"mailAddress" validate:"required"` + Username string `json:"Username" yaml:"username" validate:"required"` + Password string `json:"Password" yaml:"password" validate:"required"` + Salt string `json:"Salt" yaml:"salt" validate:"required"` + Trigger TriggerConfig `json:"Trigger" yaml:"trigger" validate:"required"` +} + +type TriggerConfig struct { + OnNewPost string `json:"OnNewPost" yaml:"onNewPost" validate:"cron,required"` +} + func LoadConfig(path string, config *Config) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/config/config.local.yaml b/config/config.local.yaml new file mode 100644 index 0000000..e23c808 --- /dev/null +++ b/config/config.local.yaml @@ -0,0 +1,47 @@ +logLevel: debug +blogPages: + storage: + type: b2 + config: + bucketName: sayana-pages + region: eu-central-003 + prefix: 'stage-' + keyID: '${B2_KEY_ID}' + applicationKey: '${B2_APPLICATION_KEY}' +factGiver: + storage: + type: b2 + config: + bucketName: sayana-pages + region: eu-central-003 + prefix: '' + keyID: '${B2_KEY_ID}' + applicationKey: '${B2_APPLICATION_KEY}' + factsFileName: 'facts-*.txt' +localePath: ./locale/ +availableLanguages: + - name: ru + alt: Русский + flag: https://f003.backblazeb2.com/file/sayana-static/flags/ru.svg + locFile: localization.ru.yaml + - name: en + alt: English + flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg + locFile: localization.en.yaml +auth: + db: + type: sqlite3 + config: + # dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc&_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' + mailHost: '${MAIL_HOST}' + publicName: 'LOCAL.SAYA.TODAY' + mailAddress: '${MAIL_ADDRESS}' + username: '${MAIL_USERNAME}' + password: '${MAIL_PASSWORD}' + salt: '${MAIL_SALT}' + trigger: + onNewPost: "* * * * *" diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 2d050bf..0bee79d 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -8,6 +8,16 @@ blogPages: prefix: 'prod-' keyID: '${B2_KEY_ID}' applicationKey: '${B2_APPLICATION_KEY}' +factGiver: + storage: + type: b2 + config: + bucketName: sayana-pages + region: eu-central-003 + prefix: '' + keyID: '${B2_KEY_ID}' + applicationKey: '${B2_APPLICATION_KEY}' + factsFileName: 'facts-*.txt' localePath: ./locale/ availableLanguages: - name: ru @@ -18,3 +28,19 @@ availableLanguages: alt: English flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg locFile: localization.en.yaml +auth: + db: + type: sqlite3 + config: + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' + salt: '${AUTH_SALT}' +mail: + clientHost: '${FQDN}' + mailHost: '${MAIL_HOST}' + publicName: 'SAYA.TODAY' + mailAddress: '${MAIL_ADDRESS}' + 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 a34a33e..1c15dae 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -8,6 +8,16 @@ blogPages: prefix: 'stage-' keyID: '${B2_KEY_ID}' applicationKey: '${B2_APPLICATION_KEY}' +factGiver: + storage: + type: b2 + config: + bucketName: sayana-pages + region: eu-central-003 + prefix: '' + keyID: '${B2_KEY_ID}' + applicationKey: '${B2_APPLICATION_KEY}' + factsFileName: 'facts-*.txt' localePath: ./locale/ availableLanguages: - name: ru @@ -18,3 +28,19 @@ availableLanguages: alt: English flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg locFile: localization.en.yaml +auth: + db: + type: sqlite3 + config: + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' + salt: '${AUTH_SALT}' +mail: + clientHost: '${FQDN}' + mailHost: '${MAIL_HOST}' + publicName: 'DEMO.SAYA.TODAY' + mailAddress: '${MAIL_ADDRESS}' + username: '${MAIL_USERNAME}' + password: '${MAIL_PASSWORD}' + salt: '${MAIL_SALT}' + trigger: + onNewPost: "0/3 * * * *" diff --git a/deploy/inventory.yml b/deploy/inventory.yml index dde952e..a1ecf5d 100644 --- a/deploy/inventory.yml +++ b/deploy/inventory.yml @@ -7,6 +7,7 @@ all: saya_today_web_name: sayana-web saya_today_web_hostname: sayana-web saya_today_web_ipv4_address: 172.16.0.18 + saya_today_web_listen_address: saya.today stage: hosts: @@ -15,3 +16,4 @@ all: saya_today_web_name: sayana-demo saya_today_web_hostname: sayana-demo saya_today_web_ipv4_address: 172.16.0.17 + saya_today_web_listen_address: demo.saya.today diff --git a/deploy/playbook.yml b/deploy/playbook.yml index ca22ac3..2516f4e 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -4,6 +4,10 @@ remote_user: svc_github tasks: + - name: Create a volume + community.docker.docker_volume: + name: "{{ saya_today_web_name }}-volume" + - name: Deploy saya-today-web Docker Container community.docker.docker_container: name: "{{ saya_today_web_name }}" @@ -13,10 +17,19 @@ B2_KEY_ID: "{{ saya_today_web_b2_key_id }}" B2_APPLICATION_KEY: "{{ saya_today_web_b2_application_key }}" ENVIRONMENT: "{{ saya_today_web_environment }}" + AUTH_SALT: "{{ saya_today_web_auth_salt }}" + MAIL_HOST: "{{ saya_today_web_mail_host }}" + MAIL_ADDRESS: "{{ saya_today_web_mail_address }}" + MAIL_USERNAME: "{{ saya_today_web_mail_username }}" + MAIL_PASSWORD: "{{ saya_today_web_mail_password }}" + MAIL_SALT: "{{ saya_today_web_mail_salt }}" + FQDN: "{{ saya_today_web_listen_address }}" network_mode: caddy networks: - name: caddy ipv4_address: "{{ saya_today_web_ipv4_address }}" + volumes: + - "{{ saya_today_web_name }}-volume:/data:rw" restart_policy: unless-stopped no_log: true ... @@ -1,33 +1,43 @@ module github.com/SayaAndy/saya-today-web -go 1.24.5 +go 1.25 -require github.com/gofiber/fiber/v2 v2.52.9 +require ( + github.com/Backblaze/blazer v0.7.2 + github.com/dgraph-io/ristretto/v2 v2.3.0 + github.com/go-co-op/gocron/v2 v2.17.0 + github.com/go-playground/validator/v10 v10.28.0 + github.com/gofiber/fiber/v2 v2.52.9 + 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.43.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( - github.com/Backblaze/blazer v0.7.2 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/gofiber/template v1.8.3 // indirect - github.com/gofiber/template/html/v2 v2.1.3 // indirect - github.com/gofiber/utils v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jonboulle/clockwork v0.5.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.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect - github.com/yuin/goldmark v1.7.13 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect ) @@ -1,58 +1,95 @@ github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQM= github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk= +github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4= +github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.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/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= -github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= -github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o= -github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE= -github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= -github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/golang-migrate/migrate/v4 v4.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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/valyala/fasthttp v1.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= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +go.uber.org/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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/sys v0.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/b2/client.go b/internal/b2/client.go index 7b745a8..d712736 100644 --- a/internal/b2/client.go +++ b/internal/b2/client.go @@ -3,6 +3,7 @@ package b2 import ( "context" "fmt" + "slices" "strings" "time" @@ -34,6 +35,7 @@ func NewB2Client(cfg *config.B2Config) (*B2Client, error) { type BlogPage struct { Link string FileName string + Lang string Metadata *frontmatter.Metadata } @@ -74,6 +76,9 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { nameParts := strings.Split(linkParts[len(linkParts)-1], ".") fileName := strings.Join(nameParts[:len(linkParts)-1], ".") + tags := strings.Split(attrs.Info["tags"], ",") + slices.Sort(tags) + filePaths = append(filePaths, &BlogPage{ Link: obj.Name(), FileName: fileName, @@ -83,7 +88,7 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { ActionDate: attrs.Info["action-date"], PublishedTime: publishedTime, Thumbnail: attrs.Info["thumbnail"], - Tags: strings.Split(attrs.Info["tags"], ","), + Tags: tags, Geolocation: attrs.Info["geolocation"], }, }) diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go new file mode 100644 index 0000000..c5b3097 --- /dev/null +++ b/internal/blogtrigger/blogtrigger.go @@ -0,0 +1,72 @@ +package blogtrigger + +import ( + "fmt" + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/go-co-op/gocron/v2" +) + +type BlogTriggerScheduler struct { + s gocron.Scheduler + knownBlogPages map[string]map[string]*b2.BlogPage + b2Client *b2.B2Client + onTrigger func([]*b2.BlogPage) error +} + +func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, error) { + s, err := gocron.NewScheduler() + if err != nil { + return nil, fmt.Errorf("failed to create new scheduler: %w", err) + } + + knownBlogPages := make(map[string]map[string]*b2.BlogPage, len(availableLanguages)) + for _, lang := range availableLanguages { + knownBlogPages[lang.Name] = make(map[string]*b2.BlogPage) + } + + bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger} + defer bts.s.Start() + + bts.s.NewJob(gocron.CronJob(cron, false), gocron.NewTask(func(bts *BlogTriggerScheduler) { + posts, err := bts.scan() + if err != nil { + slog.Error("failed to execute scanning new blog pages cron job", slog.String("error", err.Error())) + return + } + if err = onTrigger(posts); err != nil { + slog.Error("error happened on callback function after scanning new blog pages", slog.String("error", err.Error())) + return + } + }, bts)) + + if _, err = bts.scan(); err != nil { + return nil, fmt.Errorf("failed to scan existing blog pages in b2: %w", err) + } + + return bts, nil +} + +func (bts *BlogTriggerScheduler) Close() error { + return bts.s.Shutdown() +} + +func (bts *BlogTriggerScheduler) scan() (newPages []*b2.BlogPage, err error) { + newPages = make([]*b2.BlogPage, 0) + for lang := range bts.knownBlogPages { + posts, err := bts.b2Client.Scan(lang + "/") + if err != nil { + return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err) + } + for _, post := range posts { + if _, ok := bts.knownBlogPages[lang][post.FileName]; !ok { + post.Lang = lang + newPages = append(newPages, post) + bts.knownBlogPages[lang][post.FileName] = post + } + } + } + return newPages, nil +} diff --git a/internal/factgiver/factgiver.go b/internal/factgiver/factgiver.go new file mode 100644 index 0000000..5b418ee --- /dev/null +++ b/internal/factgiver/factgiver.go @@ -0,0 +1,67 @@ +package factgiver + +import ( + "fmt" + "math/rand" + "regexp" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" +) + +type FactGiver struct { + b2Client *b2.B2Client + cache map[string][]string + langs []config.AvailableLanguageConfig + factsFileName string + nlRe *regexp.Regexp + randGen *rand.Rand +} + +func NewFactGiver(cfg *config.FactGiverConfig, langs []config.AvailableLanguageConfig) (*FactGiver, error) { + b2Client, err := b2.NewB2Client(&cfg.Storage.Config) + if err != nil { + return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error()) + } + + factGiver := &FactGiver{ + b2Client: b2Client, + cache: make(map[string][]string, len(langs)), + langs: langs, + factsFileName: cfg.FactsFileName, + nlRe: regexp.MustCompile(`\r?\n`), + randGen: rand.New(rand.NewSource(time.Now().UnixNano())), + } + if err = factGiver.initCache(); err != nil { + return nil, fmt.Errorf("fail to init cache for a new fact giver: %s", err.Error()) + } + + return factGiver, nil +} + +func (g *FactGiver) Give(lang string) [3]string { + factSlice := make([]string, len(g.cache[lang])) + copy(factSlice, g.cache[lang]) + g.randGen.Shuffle(len(factSlice), func(i, j int) { + factSlice[i], factSlice[j] = factSlice[j], factSlice[i] + }) + return [3]string{factSlice[0], factSlice[1], factSlice[2]} +} + +func (g *FactGiver) initCache() error { + for _, lang := range g.langs { + localFacts := strings.Replace(g.factsFileName, "*", lang.Name, 1) + factsContentBytes, err := g.b2Client.ReadAll(localFacts) + if err != nil { + return fmt.Errorf("fail to read '%s' facts file: %s", lang.Name, err.Error()) + } + factsContent := string(factsContentBytes) + g.cache[lang.Name] = g.nlRe.Split(factsContent, -1) + if g.cache[lang.Name][len(g.cache[lang.Name])-1] == "" { + g.cache[lang.Name] = g.cache[lang.Name][:len(g.cache[lang.Name])-1] + } + } + return nil +} diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go index 1a521c0..83312dc 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -16,6 +16,8 @@ type Metadata struct { Thumbnail string `yaml:"thumbnail"` Tags []string `yaml:"tags"` Geolocation string `yaml:"geolocation"` + Medley string `yaml:"medley"` + MedleyPart int `yaml:"medleyPart"` } func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go new file mode 100644 index 0000000..e53e67b --- /dev/null +++ b/internal/glightbox/block.go @@ -0,0 +1,32 @@ +package glightbox + +import ( + "time" + + "github.com/yuin/goldmark/ast" +) + +// GLightboxBlock represents a light gallery block in the AST +type GLightboxBlock struct { + ast.BaseBlock + Images []GLightboxImage + Location *time.Location +} + +type GLightboxImage struct { + URL string + Tags []string + Caption []byte +} + +var KindGLightboxBlock = ast.NewNodeKind("GLightboxBlock") + +// Dump implements ast.Node.Dump +func (n *GLightboxBlock) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// Kind implements ast.Node.Kind +func (n *GLightboxBlock) Kind() ast.NodeKind { + return KindGLightboxBlock +} diff --git a/internal/lightgallery/extension.go b/internal/glightbox/extension.go index 761dc05..7fd17b1 100644 --- a/internal/lightgallery/extension.go +++ b/internal/glightbox/extension.go @@ -1,4 +1,4 @@ -package lightgallery +package glightbox import ( "github.com/yuin/goldmark" @@ -8,21 +8,21 @@ import ( ) // Extension that combines parser and renderer -type LightGalleryExtension struct{} +type GLightboxExtension struct{} -func NewLightGalleryExtension() goldmark.Extender { - return &LightGalleryExtension{} +func NewGLightboxExtension() goldmark.Extender { + return &GLightboxExtension{} } -func (e *LightGalleryExtension) Extend(m goldmark.Markdown) { +func (e *GLightboxExtension) Extend(m goldmark.Markdown) { m.Parser().AddOptions( parser.WithBlockParsers( - util.Prioritized(NewLightGalleryParser(), 500), + util.Prioritized(NewGLightboxParser(), 500), ), ) m.Renderer().AddOptions( renderer.WithNodeRenderers( - util.Prioritized(NewLightGalleryHTMLRenderer(), 500), + util.Prioritized(NewGLightboxHTMLRenderer(), 500), ), ) } diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go new file mode 100644 index 0000000..7022793 --- /dev/null +++ b/internal/glightbox/html_renderer.go @@ -0,0 +1,186 @@ +package glightbox + +import ( + "bytes" + "fmt" + "math/rand" + "regexp" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/internal/tailwind" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +type GLightboxHTMLRenderer struct { + html.Config + md goldmark.Markdown + anchorMatchRe *regexp.Regexp +} + +func NewGLightboxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &GLightboxHTMLRenderer{ + Config: html.NewConfig(), + md: goldmark.New( + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + parser.WithAttribute(), + ), + goldmark.WithRenderer( + renderer.NewRenderer( + renderer.WithNodeRenderers( + util.Prioritized(tailwind.NewCustomLinkRenderer( + html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(), + ), 50), + util.Prioritized(html.NewRenderer( + html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(), + ), 100), + ), + ), + ), + ), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + r.anchorMatchRe = regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`) + return r +} + +func (r *GLightboxHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindGLightboxBlock, r.renderGLightbox) +} + +func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + gallery := n.(*GLightboxBlock) + + if len(gallery.Images) == 0 { + return ast.WalkContinue, nil + } + + galleryID := generateDivId(8) + + var elements []string + for i, img := range gallery.Images { + imageUrlSegments := strings.Split(img.URL, ".") + imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") + imageUrlParts := strings.Split(imageUrlWithoutExt, "/") + imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-") + + var captionBuf bytes.Buffer + captionHTML := img.Caption + if err := r.md.Convert(img.Caption, &captionBuf); err == nil { + captionHTML = captionBuf.Bytes() + captionHTML = bytes.TrimPrefix(captionHTML, []byte("<p>")) + captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>\n")) + captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>")) + } + + dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1]) + dayDate = dayDate.In(gallery.Location) + + glightboxDescId := "" + if len(captionHTML) != 0 { + glightboxDescId = fmt.Sprintf("glightbox-desc-%s-%d", galleryID, i) + } + + dataDescriptionAttribute := "" + if glightboxDescId != "" { + dataDescriptionAttribute = fmt.Sprintf("data-description=\".%s\"", glightboxDescId) + } + + tagClassList := make([]string, 0, len(img.Tags)) + for _, tag := range img.Tags { + switch tag { + case "2x": + tagClassList = append(tagClassList, "grid-item-2x") + } + } + + if len(img.Caption) > 0 { + tagClassList = append(tagClassList, "grid-tooltip") + } + + anchorlessCaptionHTML := r.anchorMatchRe.ReplaceAll(captionHTML, []byte("<span class=\"linklike\" $1 $3>$4</span>")) + + elements = append(elements, fmt.Sprintf(` + <a href="https://f003.backblazeb2.com/file/sayana-photos/full/%s" class="glightbox grid-item %s grid-item-%s p-1" + data-gallery="gallery" data-title="%s" %s> + <picture> + <source media="(width < 800px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp" /> + <source media="(width < 2400px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" /> + <source media="(width < 3200px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp" /> + <source media="(width < 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp" /> + <source media="(width >= 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp" /> + <img src="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" /> + </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)) + + if glightboxDescId != "" { + elements = append(elements, fmt.Sprintf(` + <div class="glightbox-desc rounded-4 %s"> + <p>%s</p> + </div>`, glightboxDescId, captionHTML)) + } + } + + w.WriteString(fmt.Sprintf(` +<div class="justify-content-center display-block"> + <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> + <div class="grid masonry-grid-%s mx-auto"> + <div class="grid-sizer grid-sizer-%s"></div> + %s + </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"))) + + 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 imgLoad_%s_timer; + var imgLoad_%s = imagesLoaded('.masonry-grid-%s'); + + function initMasonryLayout_%s(tm) { + clearTimeout(imgLoad_%s_timer); + imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100); + } + + imgLoad_%s.on('progress', initMasonryLayout_%s); + + window.addEventListener('resize', initMasonryLayout_%s); + + document.addEventListener('popout', (e) => { + window.removeEventListener('resize', initMasonryLayout_%s); + imgLoad_%s.off('progress', initMasonryLayout_%s); + initMasonryLayout_%s = null; + imgLoad_%s = null; + }, {once: true}); +</script>`, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID)) + } + + return ast.WalkContinue, nil +} + +func generateDivId(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz" + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} diff --git a/internal/lightgallery/parser.go b/internal/glightbox/parser.go index daec797..6780090 100644 --- a/internal/lightgallery/parser.go +++ b/internal/glightbox/parser.go @@ -1,9 +1,10 @@ -package lightgallery +package glightbox import ( "bytes" "log/slog" "regexp" + "strings" "time" "github.com/yuin/goldmark/ast" @@ -11,17 +12,17 @@ import ( "github.com/yuin/goldmark/text" ) -type LightGalleryParser struct{} +type GLightboxParser struct{} -func NewLightGalleryParser() parser.BlockParser { - return &LightGalleryParser{} +func NewGLightboxParser() parser.BlockParser { + return &GLightboxParser{} } -func (p *LightGalleryParser) Trigger() []byte { +func (p *GLightboxParser) Trigger() []byte { return []byte{'{'} } -func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { +func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { line, _ := reader.PeekLine() if !bytes.HasPrefix(line, []byte("{Gallery:")) { @@ -43,10 +44,10 @@ func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser return nil, parser.NoChildren } - return &LightGalleryBlock{Location: loc}, parser.NoChildren + return &GLightboxBlock{Location: loc}, parser.NoChildren } -func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { +func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { line, segment := reader.PeekLine() if len(line) == 0 || segment.Len() == 0 { return parser.Continue | parser.NoChildren @@ -58,31 +59,42 @@ func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc pars return parser.Close } - gallery := node.(*LightGalleryBlock) + gallery := node.(*GLightboxBlock) - parts := bytes.SplitN(trimmed, []byte{'|'}, 2) + parts := bytes.SplitN(trimmed, []byte{'|'}, 3) url := bytes.TrimSpace(parts[0]) caption := make([]byte, 0) - if len(parts) > 1 { + tagsRaw := "" + if len(parts) == 2 { caption = bytes.TrimSpace(parts[1]) } + if len(parts) >= 3 { + tagsRaw = string(parts[1]) + caption = bytes.TrimSpace(parts[2]) + } + + tags := strings.Split(tagsRaw, ",") + for i := range tags { + tags[i] = strings.TrimSpace(tags[i]) + } - gallery.Images = append(gallery.Images, LightGalleryImage{ + gallery.Images = append(gallery.Images, GLightboxImage{ URL: string(url), + Tags: tags, Caption: caption, }) return parser.Continue | parser.NoChildren } -func (p *LightGalleryParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { +func (p *GLightboxParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { } -func (p *LightGalleryParser) CanInterruptParagraph() bool { +func (p *GLightboxParser) CanInterruptParagraph() bool { return true } -func (p *LightGalleryParser) CanAcceptIndentedLine() bool { +func (p *GLightboxParser) CanAcceptIndentedLine() bool { return false } diff --git a/internal/lightgallery/block.go b/internal/lightgallery/block.go deleted file mode 100644 index 8eb7c9e..0000000 --- a/internal/lightgallery/block.go +++ /dev/null @@ -1,31 +0,0 @@ -package lightgallery - -import ( - "time" - - "github.com/yuin/goldmark/ast" -) - -// LightGalleryBlock represents a light gallery block in the AST -type LightGalleryBlock struct { - ast.BaseBlock - Images []LightGalleryImage - Location *time.Location -} - -type LightGalleryImage struct { - URL string - Caption []byte -} - -var KindLightGalleryBlock = ast.NewNodeKind("LightGalleryBlock") - -// Dump implements ast.Node.Dump -func (n *LightGalleryBlock) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -// Kind implements ast.Node.Kind -func (n *LightGalleryBlock) Kind() ast.NodeKind { - return KindLightGalleryBlock -} diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go deleted file mode 100644 index e20e9ec..0000000 --- a/internal/lightgallery/html_renderer.go +++ /dev/null @@ -1,134 +0,0 @@ -package lightgallery - -import ( - "bytes" - "fmt" - "math/rand" - "strings" - "time" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/util" -) - -type LightGalleryHTMLRenderer struct { - html.Config - md goldmark.Markdown -} - -func NewLightGalleryHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { - r := &LightGalleryHTMLRenderer{ - Config: html.NewConfig(), - md: goldmark.New( - goldmark.WithRendererOptions( - html.WithHardWraps(), - html.WithXHTML(), - ), - ), - } - for _, opt := range opts { - opt.SetHTMLOption(&r.Config) - } - return r -} - -func (r *LightGalleryHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(KindLightGalleryBlock, r.renderLightGallery) -} - -func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - gallery := n.(*LightGalleryBlock) - - if len(gallery.Images) == 0 { - return ast.WalkContinue, nil - } - - galleryID := generateDivId(8) - - w.WriteString(fmt.Sprintf(` -<div class="justify-content-center display-block m-2"> - <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto"> - <div id="lg-%s" class="inline-gallery-container relative ml-auto mr-auto"></div> - <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto"> -</div>`, galleryID)) - - var dynamicElements []string - for _, img := range gallery.Images { - imageUrlSegments := strings.Split(img.URL, ".") - imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") - imageNameParts := strings.Split(imageUrlWithoutExt, "-") - - var captionBuf bytes.Buffer - captionHTML := img.Caption - if err := r.md.Convert(img.Caption, &captionBuf); err == nil { - captionHTML = captionBuf.Bytes() - captionHTML = bytes.TrimPrefix(captionHTML, []byte("<p>")) - captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>\n")) - captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>")) - } - - dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1]) - dayDate = dayDate.In(gallery.Location) - dynamicElements = append(dynamicElements, fmt.Sprintf(`{ - src: - "https://f003.backblazeb2.com/file/sayana-photos/full/%s", - downloadUrl: - "https://f003.backblazeb2.com/file/sayana-photos/full/%s", - alt: "%s", - responsive: "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp 384, https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp 672, https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp 960, https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp 1440, https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp 1920", - thumb: - "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", - subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions"> - <p class="grow !text-[1vmax]/[1] text-left font-spectral text-main-dark">%s</p> - <p class="!text-[1vmax]/[1] text-right font-spectral text-secondary">%s</p> - </div>`+"`"+` - }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00"))) - } - - w.WriteString(fmt.Sprintf(` -<script> -function createLightGallery%s() { - const $lgContainer = document.getElementById('lg-%s'); - const inlineGallery = lightGallery($lgContainer, { - container: $lgContainer, - dynamic: true, - dynamicEl: [%s], - width: "100%%", - height: "50vmin", - hash: false, - closable: false, - showMaximizeIcon: true, - appendSubHtmlTo: ".lg-sub-html", - isMobile: () => false, - slideDelay: 0, - plugins: [lgZoom, lgThumbnail], - thumbWidth: calculateVmin(10), - thumbHeight: "10vmin", - thumbMargin: 4 - }); - - setTimeout(() => { - inlineGallery.openGallery(); - }, 200); -} - -document.addEventListener('DOMContentLoaded', createLightGallery%s); -</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID)) - } - - return ast.WalkContinue, nil -} - -func generateDivId(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyz" - seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..d6dbd17 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,544 @@ +package mailer + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/binary" + "fmt" + "html/template" + "log/slog" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/templatemanager" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/dgraph-io/ristretto/v2" + "github.com/gofiber/fiber/v2" + "github.com/wneessen/go-mail" + "golang.org/x/crypto/argon2" +) + +type Mailer struct { + verificationCodes *ristretto.Cache[uint64, string] + unsubscribeCodes *ristretto.Cache[uint64, []byte] + db *sql.DB + tm *templatemanager.TemplateManager + mailClient *mail.Client + clientHost string + mailAddress string + publicName string + salt []byte + + lostMailMap map[string]struct { + Dur time.Duration + End time.Time + CodeExpiry time.Time + } + lostMailMapMutex sync.RWMutex + + hashMap map[string][]byte + hashMapMutex sync.RWMutex + + l map[string]*locale.LocaleConfig +} + +type SubscriptionType int + +const ( + All SubscriptionType = iota + None + Specific +) + +func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) { + verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, string]{ + NumCounters: 10000, + MaxCost: 1 << 20, // 1 MB + BufferItems: 64, + TtlTickerDurationInSec: 3600, // 1 hour + }) + if err != nil { + return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) + } + + unsubscribeCodes, err := ristretto.NewCache(&ristretto.Config[uint64, []byte]{ + NumCounters: 10000, + MaxCost: 1 << 20, // 1 MB + BufferItems: 64, + TtlTickerDurationInSec: 86400, // 1 day + }) + if err != nil { + return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) + } + + tm, err := templatemanager.NewTemplateManager(templatemanager.TemplateManagerTemplates{ + Name: "new-post", + Files: []string{"views/layouts/general-mail.html", "views/messages/new-post.html"}, + }, templatemanager.TemplateManagerTemplates{ + Name: "verify-email", + Files: []string{"views/layouts/general-mail.html", "views/messages/verify-email.html"}, + }) + if err != nil { + return nil, fmt.Errorf("fail to initialize template manager for message templating: %w", err) + } + + mailClient, err := mail.NewClient(mailHost, + mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSMandatory), + mail.WithUsername(username), mail.WithPassword(password), + ) + if err != nil { + return nil, fmt.Errorf("fail to initialize mail client: %w", err) + } + + return &Mailer{ + verificationCodes: verificationCodes, + unsubscribeCodes: unsubscribeCodes, + db: db, + clientHost: clientHost, + tm: tm, + mailClient: mailClient, + mailAddress: mailAddress, + publicName: publicName, + salt: salt, + hashMap: make(map[string][]byte), + lostMailMap: make(map[string]struct { + Dur time.Duration + End time.Time + CodeExpiry time.Time + }, 0), + l: localization}, nil +} + +func (m *Mailer) GetHash(id string) []byte { + m.hashMapMutex.RLock() + if val, ok := m.hashMap[id]; ok { + m.hashMapMutex.RUnlock() + slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + return val + } + m.hashMapMutex.RUnlock() + + m.hashMapMutex.Lock() + defer m.hashMapMutex.Unlock() + + if val, ok := m.hashMap[id]; ok { + slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + return val + } + + m.hashMap[id] = argon2.IDKey([]byte(id), m.salt, 1, 64*1024, 4, 32) + slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(m.hashMap[id]))) + return m.hashMap[id] +} + +func (m *Mailer) IsAllowedToRetryVerification(userId string) (retryAllowed bool, whenAllowed time.Time, codeExpiry time.Time) { + m.lostMailMapMutex.RLock() + defer m.lostMailMapMutex.RUnlock() + previous, ok := m.lostMailMap[userId] + if ok && previous.End.After(time.Now()) { + return false, previous.End, previous.CodeExpiry + } + return true, time.Time{}, previous.CodeExpiry +} + +func (m *Mailer) MailIsTaken(email string) (bool, error) { + tx, err := m.db.Begin() + if err != nil { + 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 rows.Close() + + isTaken := rows.Next() + return isTaken, nil +} + +func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err error) { + tx, err := m.db.Begin() + if err != nil { + return "", "", fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + slog.Debug("began db transaction", slog.String("method", "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 rows.Close() + + if !rows.Next() { + return "", "", nil + } + + if err = rows.Scan(&email, &lang); err != nil { + return "", "", fmt.Errorf("failed to scan the result from user-email settings query: %s", err) + } + return +} + +func (m *Mailer) Unsubscribe(unsubscribeCodeString string) (clientError error, serverError error) { + unsubscribeCode, err := strconv.ParseUint(unsubscribeCodeString, 16, 64) + if err != nil { + return fmt.Errorf("invalid unsubscribe code: %s", err), nil + } + + userId, _ := m.unsubscribeCodes.Get(unsubscribeCode) + if len(userId) == 0 { + return fmt.Errorf("invalid unsubscribe code: have no information about it"), nil + } + + if err = m.Subscribe(userId, None); err != nil { + return nil, fmt.Errorf("failed to unsubscribe: %s", err) + } + + return nil, nil +} + +func (m *Mailer) SendVerificationCode(userId string, address string, lang string) error { + message := mail.NewMsg() + + if err := message.EnvelopeFrom(m.mailAddress); err != nil { + return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err) + } + if err := message.FromFormat(m.publicName, m.mailAddress); err != nil { + return fmt.Errorf("failed to set formatted FROM address: %w", err) + } + if err := message.To(address); err != nil { + return fmt.Errorf("failed to set TO address: %w", err) + } + + message.SetMessageID() + message.SetDate() + message.SetBulk() + + dur := 1 * time.Minute + m.lostMailMapMutex.Lock() + if previous, ok := m.lostMailMap[userId]; ok { + if time.Now().Before(previous.End) { + m.lostMailMapMutex.Unlock() + return fmt.Errorf("user is not allowed to send another verification code until %s", previous.End) + } + dur = 2 * m.lostMailMap[userId].Dur + } + m.lostMailMap[userId] = struct { + Dur time.Duration + End time.Time + CodeExpiry time.Time + }{Dur: dur, End: time.Now().Add(dur), CodeExpiry: time.Now().Add(time.Hour)} + m.lostMailMapMutex.Unlock() + + verificationCodeBytes := make([]byte, 8) + rand.Read(verificationCodeBytes) + verificationCode := binary.LittleEndian.Uint64(verificationCodeBytes) + + verificationInfo := fmt.Sprintf("%s.%s", base64.RawStdEncoding.EncodeToString([]byte(userId)), base64.RawStdEncoding.EncodeToString([]byte(address))) + m.verificationCodes.Set(verificationCode, verificationInfo, int64(len(verificationInfo)+8)) + + message.Subject(m.l[lang].Mail.VerifyEmail.Subject) + + msg, err := m.tm.Render("verify-email", fiber.Map{ + "L": m.l[lang], + "Lang": lang, + "VerificationCode": fmt.Sprintf("%X", verificationCode), + "ClientHost": m.clientHost, + }) + if err != nil { + return fmt.Errorf("failed to render message body: %w", err) + } + + message.SetBodyString(mail.TypeTextHTML, string(msg)) + if err := m.mailClient.DialAndSend(message); err != nil { + return fmt.Errorf("failed to send verification code message: %w", err) + } + slog.Debug("verification code message successfully delivered", slog.String("address", address), slog.String("user_id", userId)) + return nil +} + +func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error { + verificationCode, err := strconv.ParseUint(verificationCodeEncoded, 16, 64) + if err != nil { + return fmt.Errorf("failed to decode verification code from 8-byte hex: %w", err) + } + + verificationInfo, _ := m.verificationCodes.Get(verificationCode) + if verificationInfo == "" { + return fmt.Errorf("failed to get verification info by its code (might be absent, might be empty)") + } + + verificationSegments := strings.Split(verificationInfo, ".") + if len(verificationSegments) != 2 { + m.verificationCodes.Del(verificationCode) + return fmt.Errorf("invalid format of verification info: expected %d segments, got %d", 2, len(verificationSegments)) + } + + userId, err := base64.RawStdEncoding.DecodeString(verificationSegments[0]) + if err != nil { + m.verificationCodes.Del(verificationCode) + delete(m.lostMailMap, verificationSegments[0]) + return fmt.Errorf("could not decode user id: %s", err) + } + + address, err := base64.RawStdEncoding.DecodeString(verificationSegments[1]) + if err != nil { + m.verificationCodes.Del(verificationCode) + delete(m.lostMailMap, verificationSegments[0]) + return fmt.Errorf("could not decode address: %s", err) + } + + tx, err := m.db.Begin() + if err != nil { + return fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + slog.Debug("began db transaction", slog.String("method", "Verify")) + if _, err = tx.Exec(`INSERT INTO user_email_table(user_id, email, lang) VALUES(?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + email=excluded.email, + lang=excluded.lang;`, m.GetHash(string(userId)), address, lang); err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Verify")) + return fmt.Errorf("failed to configure user-email settings in db: %s", err) + } + + if err = tx.Commit(); err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Verify")) + return fmt.Errorf("failed to commit transaction to db: %s", err) + } + slog.Debug("ended db transaction", slog.String("method", "Verify")) + + m.verificationCodes.Del(verificationCode) + delete(m.lostMailMap, verificationSegments[0]) + return nil +} + +func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionType, tags []string, err error) { + tx, err := m.db.Begin() + if err != nil { + return None, nil, fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + slog.Debug("began db transaction", slog.String("method", "GetSubscriptions")) + defer func(tx *sql.Tx) { + if err = tx.Commit(); err != nil { + tx.Rollback() + } + slog.Debug("ended db transaction", slog.String("method", "GetSubscriptions")) + }(tx) + + hash := m.GetHash(userId) + + var rows *sql.Rows + if rows, err = tx.Query(`SELECT tags FROM subscription_user_to_tags_table WHERE user_id=? LIMIT 1;`, hash); err != nil { + tx.Rollback() + return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err) + } + defer rows.Close() + + if !rows.Next() { + return None, nil, nil + } + + tagsString := "" + if err = rows.Scan(&tagsString); err != nil { + return None, nil, fmt.Errorf("failed to scan the result from user-to-tags query: %s", err) + } + + switch tagsString { + case "": + return None, nil, nil + case "_all": + return All, nil, nil + default: + return Specific, strings.Split(tagsString, ","), nil + } +} + +func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, tags ...string) error { + tx, err := m.db.Begin() + if err != nil { + return fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + slog.Debug("began db transaction", slog.String("method", "Subscribe")) + + slices.Sort(tags) + tagsOutput := "" + switch subscriptionType { + case All: + tagsOutput = "_all" + case None: + tagsOutput = "" + case Specific: + tagsOutput = strings.Join(tags, ",") + } + + if _, err = tx.Exec(`INSERT INTO subscription_user_to_tags_table(user_id, tags) VALUES(?, ?) + ON CONFLICT(user_id) DO UPDATE SET + tags=excluded.tags;`, userIdHash, tagsOutput); err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Subscribe")) + return fmt.Errorf("failed to configure user-to-tags table in db for the user: %s", err) + } + + if err = tx.Commit(); err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Subscribe")) + return fmt.Errorf("failed to commit transaction to db: %s", err) + } + slog.Debug("ended db transaction", slog.String("method", "Subscribe")) + + return nil +} + +func (m *Mailer) NewPost(post *b2.BlogPage) error { + tx, err := m.db.Begin() + if err != nil { + return fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + slog.Debug("began db transaction", slog.String("method", "NewPost")) + var rows *sql.Rows + if rows, err = tx.Query(`SELECT user_id, tags FROM subscription_user_to_tags_table;`); err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "NewPost")) + return fmt.Errorf("failed to query user-to-tags table in db: %s", err) + } + + var userId []byte + usersToSend := make([]struct { + userId []byte + email string + }, 0) + var tagsString string + i := -1 + +rowLoop: + for rows.Next() { + i++ + if err = rows.Scan(&userId, &tagsString); err != nil { + slog.Warn("failed to scan a row in user-to-tags table", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) + continue + } + + if tagsString == "" { + continue + } + + if tagsString == "_all" { + usersToSend = append(usersToSend, struct { + userId []byte + email string + }{userId, ""}) + continue + } + + pageTags := strings.Split(tagsString, ",") + for _, tag := range pageTags { + if _, found := slices.BinarySearch(post.Metadata.Tags, tag); found { + usersToSend = append(usersToSend, struct { + userId []byte + email string + }{userId, ""}) + continue rowLoop + } + } + } + + tx.Commit() + rows.Close() + slog.Debug("ended db transaction", slog.String("method", "NewPost")) + + for i := range usersToSend { + email, lang, err := m.GetInfo(usersToSend[i].userId) + if err != nil { + slog.Warn("failed to get info about the user", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) + continue + } + + if lang != post.Lang { + continue + } + + usersToSend[i].email = email + } + + messages := make([]*mail.Msg, 0, len(usersToSend)) + for _, user := range usersToSend { + if user.email == "" { + continue + } + unsubscribeCodeBytes := make([]byte, 8) + rand.Read(unsubscribeCodeBytes) + unsubscribeCode := binary.LittleEndian.Uint64(unsubscribeCodeBytes) + + unsubscribeFooter := strings.Replace(m.l[post.Lang].Mail.UnsubscribeFooter, "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1) + unsubscribeFooter = strings.Replace(unsubscribeFooter, "{/}", "</a>", 1) + + msgBody, err := m.tm.Render("new-post", fiber.Map{ + "L": m.l[post.Lang], + "Lang": post.Lang, + "Post": post, + "ClientHost": m.clientHost, + "UnsubscribeFooter": template.HTML(unsubscribeFooter), + }) + + if err != nil { + return fmt.Errorf("failed to render message body: %w", err) + } + + message := mail.NewMsg() + + if err := message.EnvelopeFrom(m.mailAddress); err != nil { + return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err) + } + if err := message.FromFormat(m.publicName, m.mailAddress); err != nil { + return fmt.Errorf("failed to set formatted FROM address: %w", err) + } + if err := message.To(user.email); err != nil { + return fmt.Errorf("failed to set TO address: %w", err) + } + + message.SetMessageID() + message.SetDate() + message.SetBulk() + message.Subject(m.l[post.Lang].Mail.NewPost.Subject) + message.SetBodyString(mail.TypeTextHTML, string(msgBody)) + + m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40) + + messages = append(messages, message) + } + + if err := m.mailClient.DialAndSend(messages...); err != nil { + return fmt.Errorf("failed to send new post notifications: %w", err) + } + return nil +} diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go deleted file mode 100644 index 2997407..0000000 --- a/internal/router/api-v1-blog-search.go +++ /dev/null @@ -1,109 +0,0 @@ -package router - -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/templatemanager" - "github.com/SayaAndy/saya-today-web/locale" - "github.com/gofiber/fiber/v2" -) - -func Api_V1_BlogSearch(tm *templatemanager.TemplateManager, l map[string]*locale.LocaleConfig, langs []string, 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") - - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) - } - - 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)) - } - - 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)) - } - - 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, err := 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, - }) - 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-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..a859d10 --- /dev/null +++ b/internal/router/basic-handler.go @@ -0,0 +1,62 @@ +package router + +import ( + "time" + + "github.com/gofiber/fiber/v2" +) + +type BasicHandler struct{} + +var _ Route = &BasicHandler{} + +func (r *BasicHandler) Filter() (method string, path string) { + panic("handler did not implement Filter method") +} + +func (r *BasicHandler) IsTemplated() bool { + return false +} + +func (r *BasicHandler) ToCache() CacheSetting { + return Disabled +} + +func (r *BasicHandler) CacheDuration() time.Duration { + return 5 * time.Minute +} + +func (r *BasicHandler) ToValidateLang() LangSetting { + return NotRequired +} + +func (r *BasicHandler) TemplatesToInject() []string { + return []string{} +} + +func (r *BasicHandler) Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + if !r.IsTemplated() { + panic("handler did not implement Render method (while being non-templated)") + } + return fiber.StatusNoContent, nil +} + +func (r *BasicHandler) RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + return fiber.StatusNoContent, nil +} + +func (r *BasicHandler) RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + return fiber.StatusNoContent, nil +} + +func (r *BasicHandler) RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + return fiber.StatusNoContent, nil +} + +func (r *BasicHandler) RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + return fiber.StatusNoContent, nil +} + +func (r *BasicHandler) RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + return fiber.StatusNoContent, nil +} diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go new file mode 100644 index 0000000..7c28b9e --- /dev/null +++ b/internal/router/client-cache.go @@ -0,0 +1,318 @@ +package router + +import ( + "database/sql" + "encoding/base64" + "fmt" + "log/slog" + "strings" + "sync" + + "golang.org/x/crypto/argon2" +) + +type ClientCache struct { + hashMap map[string]string + hashMapMutex sync.RWMutex + + likePageMap map[string]map[string]struct{} + viewPageMap map[string]map[string]struct{} + pageMutexMap map[string]*sync.RWMutex + pageMutexMapMutex sync.Mutex + + salt []byte + db *sql.DB +} + +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) + } + + likePageMap := make(map[string]map[string]struct{}) + viewPageMap := make(map[string]map[string]struct{}) + pageMutexMap := make(map[string]*sync.RWMutex) + + for rows.Next() { + var pageRef string + var userId []byte + if err = rows.Scan(&pageRef, &userId); err != nil { + tx.Rollback() + return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err) + } + userIdString := base64.RawStdEncoding.EncodeToString(userId) + if _, ok := likePageMap[pageRef]; !ok { + likePageMap[pageRef] = make(map[string]struct{}) + viewPageMap[pageRef] = make(map[string]struct{}) + pageMutexMap[pageRef] = &sync.RWMutex{} + } + likePageMap[pageRef][userIdString] = struct{}{} + viewPageMap[pageRef][userIdString] = struct{}{} + } + + rows, err = tx.Query("select * from blog_views;") + if err != nil { + tx.Rollback() + 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) + } + + for rows.Next() { + var pageRef string + var userId []byte + if err = rows.Scan(&pageRef, &userId); err != nil { + tx.Rollback() + return nil, fmt.Errorf("fail scanning blog_views to fill cache: %w", err) + } + userIdString := base64.RawStdEncoding.EncodeToString(userId) + if _, ok := viewPageMap[pageRef]; !ok { + viewPageMap[pageRef] = make(map[string]struct{}) + pageMutexMap[pageRef] = &sync.RWMutex{} + } + viewPageMap[pageRef][userIdString] = struct{}{} + } + + if err = tx.Commit(); err != nil { + 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), + likePageMap: likePageMap, + viewPageMap: viewPageMap, + pageMutexMap: pageMutexMap, + salt: salt, + db: db, + }, nil +} + +func (c *ClientCache) Close() error { + tx, err := c.db.Begin() + if err != nil { + return fmt.Errorf("fail to init transaction with db to dump cache: %w", err) + } + slog.Debug("began db transaction in ClientCache.Close") + + if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil { + tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") + return fmt.Errorf("fail to save blog_likes: %s", err) + } + + if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil { + tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") + return fmt.Errorf("fail to save blog_views: %s", err) + } + + if err = tx.Commit(); err != nil { + tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") + return fmt.Errorf("fail to commit all the changes related to cache: %s", err) + } + + slog.Debug("ended db transaction in ClientCache.Close") + return nil +} + +func (c *ClientCache) GetHash(id string) string { + c.hashMapMutex.RLock() + if val, ok := c.hashMap[id]; ok { + c.hashMapMutex.RUnlock() + slog.Debug("gave an old hash", slog.String("hash", val)) + return val + } + c.hashMapMutex.RUnlock() + + c.hashMapMutex.Lock() + defer c.hashMapMutex.Unlock() + + if val, ok := c.hashMap[id]; ok { + slog.Debug("gave a newly generated hash", slog.String("hash", val)) + return val + } + + c.hashMap[id] = base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32)) + slog.Debug("generated hash", slog.String("hash", c.hashMap[id])) + return c.hashMap[id] +} + +func (c *ClientCache) getPageMutex(page string) *sync.RWMutex { + c.pageMutexMapMutex.Lock() + defer c.pageMutexMapMutex.Unlock() + + if mutex, ok := c.pageMutexMap[page]; ok { + return mutex + } + + c.pageMutexMap[page] = &sync.RWMutex{} + return c.pageMutexMap[page] +} + +func (c *ClientCache) GetLikeStatus(id string, page string) bool { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if _, ok := c.likePageMap[page]; !ok { + return false + } + _, ok := c.likePageMap[page][c.GetHash(id)] + return ok +} + +func (c *ClientCache) GetLikeCount(page string) int { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if userSet, ok := c.likePageMap[page]; ok { + return len(userSet) + } + return 0 +} + +func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { + page = strings.Clone(page) + hash := c.GetHash(id) + + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() + + if userSet, ok := c.likePageMap[page]; ok { + _, alreadyLiked = userSet[hash] + c.likePageMap[page][hash] = struct{}{} + return + } + + c.likePageMap[page] = make(map[string]struct{}) + c.likePageMap[page][hash] = struct{}{} + return +} + +func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) { + page = strings.Clone(page) + hash := c.GetHash(id) + + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() + + if _, ok := c.likePageMap[page]; !ok { + return true + } + if _, ok := c.likePageMap[page][hash]; !ok { + return true + } + + delete(c.likePageMap[page], hash) + return false +} + +func (c *ClientCache) GetViewStatus(id string, page string) bool { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if _, ok := c.viewPageMap[page]; !ok { + return false + } + _, ok := c.viewPageMap[page][c.GetHash(id)] + return ok +} + +func (c *ClientCache) GetViewCount(page string) int { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if userSet, ok := c.viewPageMap[page]; ok { + return len(userSet) + } + return 0 +} + +func (c *ClientCache) View(id string, page string) { + page = strings.Clone(page) + hash := c.GetHash(id) + + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() + + if _, ok := c.viewPageMap[page]; !ok { + c.viewPageMap[page] = make(map[string]struct{}) + } + c.viewPageMap[page][hash] = struct{}{} +} + +func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) { + if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil { + return fmt.Errorf("fail to truncate table %s: %w", table, err) + } + + userIdBytes := make(map[string][]byte) + + sqlStatement := fmt.Sprintf(` + INSERT OR IGNORE INTO %s (page_ref, user_id) + VALUES %s(?, ?); + `, table, strings.Repeat("(?, ?), ", 99)) + sqlStatementVars := make([]any, 0, 200) + + for pageRef, userSet := range pageMap { + for userId := range userSet { + if _, ok := userIdBytes[userId]; !ok { + userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId) + if err != nil { + slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error())) + continue + } + } + + sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId])) + if len(sqlStatementVars) < 200 { + continue + } + + if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { + slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error())) + } + + sqlStatementVars = make([]any, 0, 200) + } + } + + if len(sqlStatementVars) > 0 { + sqlStatement = fmt.Sprintf(` + INSERT OR IGNORE INTO %s (page_ref, user_id) + VALUES %s(?, ?); + `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1)) + + if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { + slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error())) + } + } + + return nil +} diff --git a/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..f1be2cd --- /dev/null +++ b/internal/router/handlers/lang-blog-title.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "bytes" + "fmt" + "html/template" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" + "github.com/SayaAndy/saya-today-web/internal/router" + "github.com/gofiber/fiber/v2" + "github.com/yuin/goldmark" +) + +func init() { + router.Routes = append(router.Routes, &BlogPageHandler{}) +} + +type BlogPageHandler struct { + router.BasicHandler +} + +func (r *BlogPageHandler) Filter() (method string, path string) { + return "GET", "/:lang/blog/:title" +} + +func (r *BlogPageHandler) IsTemplated() bool { + return true +} + +func (r *BlogPageHandler) TemplatesToInject() []string { + return []string{"views/pages/blog-page.html"} +} + +func (r *BlogPageHandler) ToCache() router.CacheSetting { + return router.ByUrlOnly +} + +func (r *BlogPageHandler) CacheDuration() time.Duration { + return 15 * time.Minute +} + +func (r *BlogPageHandler) ToValidateLang() router.LangSetting { + return router.InPath +} + +func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + _, pathParts, _, err := router.GetPathFromReferer(c) + if err != nil { + return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err) + } + + metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.B2Client, lang+"/"+pathParts[2]) + if err != nil { + return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", pathParts[2], err) + } + + geolocationParts := strings.Split(metadata.Geolocation, " ") + var x, y, areaError string + if len(geolocationParts) >= 2 { + x = geolocationParts[0] + y = geolocationParts[1] + } + if len(geolocationParts) >= 3 { + areaError = geolocationParts[2] + } + + templateMap["MapLocationX"] = x + templateMap["MapLocationY"] = y + templateMap["MapLocationAreaMeters"] = areaError + templateMap["Title"] = metadata.Title + templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown) + templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00") + templateMap["ActionDate"] = metadata.ActionDate + + 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 + templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00") + templateMap["ActionDate"] = metadata.ActionDate + + 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..dbf1437 --- /dev/null +++ b/internal/router/handlers/lang-map.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/router" + "github.com/gofiber/fiber/v2" +) + +type MapHandler struct { + router.BasicHandler +} + +func init() { + router.Routes = append(router.Routes, &MapHandler{}) +} + +func (r *MapHandler) Filter() (method string, path string) { + return "GET", "/:lang/map" +} + +func (r *MapHandler) IsTemplated() bool { + return false +} + +func (r *MapHandler) TemplatesToInject() []string { + return []string{"views/pages/global-map.html"} +} + +func (r *MapHandler) ToCache() router.CacheSetting { + return router.Disabled +} + +func (r *MapHandler) ToValidateLang() router.LangSetting { + return router.InPath +} + +func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + pages, err := supplements.B2Client.Scan(lang + "/") + status := fiber.StatusOK + if err != nil { + slog.Error("received an error while scanning b2 pages", + slog.String("error", err.Error()), + slog.String("lang", lang), + ) + status = fiber.StatusPartialContent + pages = []*b2.BlogPage{} + } + + type MapMarker struct { + Title string `json:"Title"` + PageLink string `json:"PageLink"` + Lat float64 `json:"Lat"` + Long float64 `json:"Long"` + AccuracyMeters int64 `json:"AccuracyMeters"` + Thumbnail string `json:"Thumbnail"` + } + + mapMarkers := make([]*MapMarker, 0, len(pages)) + for _, page := range pages { + geolocationParts := strings.Split(page.Metadata.Geolocation, " ") + if len(geolocationParts) < 2 { + continue + } + + var x, y float64 + var areaError int64 + if len(geolocationParts) >= 2 { + x, _ = strconv.ParseFloat(geolocationParts[0], 64) + y, _ = strconv.ParseFloat(geolocationParts[1], 64) + } + if len(geolocationParts) >= 3 { + areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64) + } + + mapMarkers = append(mapMarkers, &MapMarker{ + Title: page.Metadata.Title, + PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName), + Lat: x, + Long: y, + AccuracyMeters: areaError, + Thumbnail: page.Metadata.Thumbnail, + }) + } + + templateMap["MapMarkers"] = mapMarkers + templateMap["MapLocationLat"] = 45.4507 + templateMap["MapLocationLong"] = 68.8319 + + return status, nil +} diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go 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..db566fe --- /dev/null +++ b/internal/router/handlers/lang-user.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "fmt" + "log/slog" + + "github.com/SayaAndy/saya-today-web/internal/mailer" + "github.com/SayaAndy/saya-today-web/internal/router" + "github.com/gofiber/fiber/v2" +) + +func init() { + router.Routes = append(router.Routes, &UserHandler{}) +} + +type UserHandler struct { + router.BasicHandler +} + +func (r *UserHandler) Filter() (method string, path string) { + return "GET", "/:lang/user" +} + +func (r *UserHandler) IsTemplated() bool { + return true +} + +func (r *UserHandler) TemplatesToInject() []string { + return []string{"views/pages/user-page.html"} +} + +func (r *UserHandler) ToCache() router.CacheSetting { + return router.Disabled +} + +func (r *UserHandler) ToValidateLang() router.LangSetting { + return router.InPath +} + +func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + templateMap["Title"] = supplements.Localization[lang].UserProfile.Header + + email, _, err := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(c.IP())) + if err != nil { + slog.Error("get info from mailer about a client", slog.String("error", err.Error())) + } + + tagsArray, err := getTags(supplements.B2Client, lang) + if err != nil { + return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the available tags") + } + + subscriptionType, tags, err := supplements.Mailer.GetSubscriptions(c.IP()) + if err != nil { + return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the user subscriptions") + } + + switch subscriptionType { + case mailer.None: + templateMap["TagsPicked"] = "none" + case mailer.All: + templateMap["TagsPicked"] = "all" + case mailer.Specific: + templateMap["TagsPicked"] = "specific" + } + + templateMap["TagsPickedList"] = tags + + templateMap["Email"] = email + templateMap["EmailCode"] = c.Query("email_code") + templateMap["ExistingTags"] = tagsArray + return fiber.StatusOK, nil +} + +func (r *UserHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + templateMap["Title"] = supplements.Localization[lang].UserProfile.Header + return fiber.StatusOK, nil +} diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go new file mode 100644 index 0000000..fc4deb6 --- /dev/null +++ b/internal/router/handlers/lang.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "fmt" + "math/rand/v2" + "time" + + "github.com/SayaAndy/saya-today-web/internal/router" + "github.com/gofiber/fiber/v2" +) + +func init() { + router.Routes = append(router.Routes, &HomeHandler{}) +} + +type HomeHandler struct { + router.BasicHandler +} + +func (r *HomeHandler) Filter() (method string, path string) { + return "GET", "/:lang<len(2)>" +} + +func (r *HomeHandler) IsTemplated() bool { + return true +} + +func (r *HomeHandler) TemplatesToInject() []string { + return []string{"views/pages/home-page.html"} +} + +func (r *HomeHandler) ToCache() router.CacheSetting { + return router.ByUrlOnly +} + +func (r *HomeHandler) CacheDuration() time.Duration { + return 10 * time.Minute +} + +func (r *HomeHandler) ToValidateLang() router.LangSetting { + return router.InPath +} + +func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + templateMap["Title"] = supplements.Localization[lang].HomePage.Header + templateMap["FilledHeartCount"] = uint(40) + templateMap["OutlineHeartCount"] = uint(40) + templateMap["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1) + templateMap["FunFacts"] = supplements.FactGiver.Give(lang) + return fiber.StatusOK, nil +} + +func (r *HomeHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + templateMap["Title"] = supplements.Localization[lang].HomePage.Header + return fiber.StatusOK, nil +} diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go new file mode 100644 index 0000000..c8c4cd4 --- /dev/null +++ b/internal/router/handlers/root.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "time" + + "github.com/SayaAndy/saya-today-web/internal/router" + "github.com/gofiber/fiber/v2" +) + +func init() { + router.Routes = append(router.Routes, &RootHandler{}) +} + +type RootHandler struct { + router.BasicHandler +} + +func (r *RootHandler) Filter() (method string, path string) { + return "GET", "/" +} + +func (r *RootHandler) IsTemplated() bool { + return true +} + +func (r *RootHandler) TemplatesToInject() []string { + return []string{"views/pages/language-pick.html"} +} + +func (r *RootHandler) ToCache() router.CacheSetting { + return router.ByUrlOnly +} + +func (r *RootHandler) CacheDuration() time.Duration { + return 24 * time.Hour +} + +func (r *RootHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) { + templateMap["Title"] = "Choose Your Language" + templateMap["AvailableLanguages"] = supplements.AvailableLanguages + return fiber.StatusOK, nil +} + +func (r *RootHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) { + templateMap["Title"] = "Choose Your Language" + return fiber.StatusOK, nil +} diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go deleted file mode 100644 index 39d4252..0000000 --- a/internal/router/lang-blog-title.go +++ /dev/null @@ -1,76 +0,0 @@ -package router - -import ( - "bytes" - "fmt" - "html/template" - "log/slog" - "slices" - "strconv" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/internal/frontmatter" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" - "github.com/SayaAndy/saya-today-web/locale" - "github.com/gofiber/fiber/v2" - "github.com/yuin/goldmark" -) - -func Lang_Blog_Title(tm *templatemanager.TemplateManager, l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - lang := c.Params("lang") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title")) - if err != nil { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title"))) - } - - 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] - } - - content, err := tm.Render("blog-page", fiber.Map{ - "Title": metadata.Title, - "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"), - "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()), - "ParsedMarkdown": template.HTML(parsedMarkdown), - "MapLocationX": x, - "MapLocationY": y, - "MapLocationAreaMeters": areaError, - "Lang": lang, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), 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").Send(content) - } -} - -func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { - metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md") - if err != nil { - return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) - } - - var buf bytes.Buffer - if err := md.Convert(markdown, &buf); err != nil { - return nil, "", fmt.Errorf("convert source context from md to html: %w", err) - } - - return metadata, buf.String(), nil -} diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go deleted file mode 100644 index 1a7cbca..0000000 --- a/internal/router/lang-blog.go +++ /dev/null @@ -1,90 +0,0 @@ -package router - -import ( - "fmt" - "log/slog" - "net/url" - "regexp" - "slices" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" - "github.com/SayaAndy/saya-today-web/locale" - "github.com/gofiber/fiber/v2" -) - -func Lang_Blog(tm *templatemanager.TemplateManager, l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - lang := c.Params("lang") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - 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())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") - } - decodedQuery, err := url.QueryUnescape(string(encodedQuery)) - matches := re.FindAllStringSubmatch(decodedQuery, -1) - - queryTags := make([]string, 0, len(matches)) - for _, match := range matches { - queryTags = append(queryTags, string(match[1])) - } - - pages, err := b2Client.Scan(lang + "/") - status := fiber.StatusOK - if err != nil { - status = fiber.StatusPartialContent - pages = []*b2.BlogPage{} - } - - tagsMap := make(map[string]int) - for _, page := range pages { - slog.Debug("enlist page for catalogue", slog.Any("page", page), slog.String("endpoint", "/"+lang+"/blog")) - for _, tag := range page.Metadata.Tags { - tagsMap[tag]++ - } - } - - type Tag struct { - Name string `json:"Name" yaml:"name"` - Count int `json:"Count" yaml:"count"` - } - - tagsArray := make([]Tag, 0, len(tagsMap)) - for tag, count := range tagsMap { - tagsArray = append(tagsArray, Tag{tag, count}) - } - slices.SortFunc(tagsArray, func(a Tag, b Tag) int { - return strings.Compare(a.Name, b.Name) - }) - - content, err := tm.Render("blog-catalogue", fiber.Map{ - "QuerySort": querySort, - "QueryTags": strings.Join(queryTags, ","), - "Tags": tagsArray, - "Lang": lang, - "L": l[lang], - "PublishedYear": "2025", - "Title": l[lang].BlogSearch.Header, - }) - if err != nil { - slog.Warn("failed to generate page", 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 page") - } - - return c.Type("html").Status(status).Send(content) - } -} diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go deleted file mode 100644 index 8cc070a..0000000 --- a/internal/router/lang-map.go +++ /dev/null @@ -1,82 +0,0 @@ -package router - -import ( - "fmt" - "log/slog" - "slices" - "strconv" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" - "github.com/SayaAndy/saya-today-web/locale" - "github.com/gofiber/fiber/v2" -) - -func Lang_Map(tm *templatemanager.TemplateManager, l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - lang := c.Params("lang") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - 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/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/root.go b/internal/router/root.go deleted file mode 100644 index f5be6cb..0000000 --- a/internal/router/root.go +++ /dev/null @@ -1,24 +0,0 @@ -package router - -import ( - "log/slog" - - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" - "github.com/gofiber/fiber/v2" -) - -func Root(tm *templatemanager.TemplateManager, localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - content, err := tm.Render("index", fiber.Map{ - "AvailableLanguages": localeCfg, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"), 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").Send(content) - } -} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..ef3f063 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,559 @@ +package router + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "net/url" + "slices" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blogtrigger" + "github.com/SayaAndy/saya-today-web/internal/factgiver" + "github.com/SayaAndy/saya-today-web/internal/glightbox" + "github.com/SayaAndy/saya-today-web/internal/mailer" + "github.com/SayaAndy/saya-today-web/internal/tailwind" + "github.com/SayaAndy/saya-today-web/internal/templatemanager" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/dgraph-io/ristretto/v2" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +type CacheSetting int + +const ( + Disabled CacheSetting = iota + ByUrlOnly + ByUrlAndQuery +) + +type LangSetting int + +const ( + NotRequired LangSetting = iota + InPath + InForm + InReferer +) + +var ( + Routes = make([]Route, 0) +) + +type Route interface { + Filter() (method string, path string) + IsTemplated() bool + ToCache() CacheSetting + CacheDuration() time.Duration + ToValidateLang() LangSetting + TemplatesToInject() []string + Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) + RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) + RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) + RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) + RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) + RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) +} + +type Supplements struct { + DB *sql.DB + B2Client *b2.B2Client + Localization map[string]*locale.LocaleConfig + AvailableLanguages []config.AvailableLanguageConfig + ClientCache *ClientCache + PageCache *ristretto.Cache[string, []byte] + FactGiver *factgiver.FactGiver + Mailer *mailer.Mailer + BlogTrigger *blogtrigger.BlogTriggerScheduler + TemplateManager *templatemanager.TemplateManager + MarkdownRenderer goldmark.Markdown +} + +type Router struct { + supplements *Supplements + app *fiber.App + templatedRoutes map[string]map[string]Route + templatedPathMatcher *PathMatcher +} + +func NewRouter(cfg *config.Config) (*Router, error) { + supplements := &Supplements{ + AvailableLanguages: cfg.AvailableLanguages, + } + + var err error + + supplements.DB, err = sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN) + if err != nil { + return nil, fmt.Errorf("fail to initialize db: %w", err) + } + + driver, err := sqlite3.WithInstance(supplements.DB, &sqlite3.Config{}) + if err != nil { + return nil, fmt.Errorf("fail to initialize driver for migrating db: %w", err) + } + + m, err := migrate.NewWithDatabaseInstance( + "file://migrations", + cfg.Auth.Db.Type, driver) + if err != nil { + return nil, fmt.Errorf("fail to initialize migration client: %w", err) + } + + if err = m.Up(); err != nil && err == errors.New("no change") { + return nil, fmt.Errorf("fail to apply migrations: %w", err) + } + slog.Debug("successfully applied migrations") + + supplements.B2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config) + if err != nil { + return nil, fmt.Errorf("fail to initialize b2 client: %w", err) + } + + supplements.Localization = make(map[string]*locale.LocaleConfig, len(cfg.AvailableLanguages)) + for _, lang := range cfg.AvailableLanguages { + localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile) + if err != nil { + return nil, fmt.Errorf("fail to initialize a locale: %w", err) + } + supplements.Localization[lang.Name] = localeCfg + } + + supplements.MarkdownRenderer = goldmark.New( + goldmark.WithExtensions( + glightbox.NewGLightboxExtension(), + tailwind.NewTailwindExtension(), + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + parser.WithAttribute(), + ), + goldmark.WithRenderer( + renderer.NewRenderer( + renderer.WithNodeRenderers( + util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50), + util.Prioritized(html.NewRenderer(html.WithXHTML()), 100), + ), + ), + ), + ) + + supplements.ClientCache, err = NewClientCache(supplements.DB, []byte(cfg.Auth.Salt)) + if err != nil { + return nil, fmt.Errorf("fail to initialize client cache: %w", err) + } + + supplements.PageCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{ + NumCounters: 1e6, // 1,000,000 + MaxCost: 1 << 29, // 512 MB + BufferItems: 64, // number of keys per Get buffer. + }) + if err != nil { + return nil, fmt.Errorf("fail to initialize page cache: %w", err) + } + + supplements.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, supplements.AvailableLanguages) + if err != nil { + return nil, fmt.Errorf("fail to initialize fact giver: %w", err) + } + + supplements.Mailer, err = mailer.NewMailer(supplements.DB, cfg.Mail.ClientHost, cfg.Mail.MailHost, + cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), supplements.Localization) + if err != nil { + return nil, fmt.Errorf("fail to initialize mailer: %w", err) + } + + supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.B2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost, + func(bp []*b2.BlogPage) error { + for _, post := range bp { + if err := supplements.Mailer.NewPost(post); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("fail to initialize blog trigger: %w", err) + } + + supplements.TemplateManager, err = templatemanager.NewTemplateManager() + if err != nil { + return nil, fmt.Errorf("fail to initialize template manager: %w", err) + } + + enablePrintRoutes := false + if cfg.LogLevel <= slog.LevelDebug { + enablePrintRoutes = true + } + + app := fiber.New(fiber.Config{ + EnablePrintRoutes: enablePrintRoutes, + ProxyHeader: "X-Forwarded-For", + }) + + app.Use(cors.New(cors.Config{ + AllowOrigins: "https://f003.backblazeb2.com", + AllowMethods: "GET,POST,OPTIONS", + AllowHeaders: "Origin, Content-Type, Accept", + })) + + templatedRoutes := make(map[string]map[string]Route) + templatedPathMatcher := NewPathMatcher() + + return &Router{supplements, app, templatedRoutes, templatedPathMatcher}, nil +} + +func (r *Router) InitRoutes() (err error) { + r.supplements.TemplateManager.Add("general-page", "views/layouts/general-page.html") + for _, route := range Routes { + method, match := route.Filter() + + if err = r.supplements.TemplateManager.Add(method+" "+match, route.TemplatesToInject()...); err != nil { + return fmt.Errorf("failed to add '%s %s' route into template manager: %w", method, match, err) + } + + if route.IsTemplated() { + if _, ok := r.templatedRoutes[method]; !ok { + r.templatedRoutes[method] = make(map[string]Route) + } + r.templatedRoutes[method][match] = route + r.templatedPathMatcher.AddRoute(method, match) + + currentRoute := route + r.app.Add(method, match, func(c *fiber.Ctx) error { + lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang()) + if err != nil { + return err + } + err = r.generalPage(c, currentRoute, lang) + return err + }) + } else { + currentRoute := route + r.app.Add(method, match, func(c *fiber.Ctx) error { + lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang()) + if err != nil { + return err + } + + var cacheKey string + trimmedPath := strings.Trim(c.Path(), "/") + queryString := c.Request().URI().QueryString() + + switch currentRoute.ToCache() { + case ByUrlOnly: + cacheKey = fmt.Sprintf("%s.full-page.%s", method, trimmedPath) + case ByUrlAndQuery: + cacheKey = fmt.Sprintf("%s.full-page.%s.%s", method, trimmedPath, queryString) + } + + defaultMap := fiber.Map{ + "L": r.supplements.Localization[lang], + "Lang": lang, + "Path": trimmedPath, + "QueryString": queryString, + } + + statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap) + method := c.Method() + _, match := currentRoute.Filter() + if err != nil { + slog.Error("failed to finish rendering a page", + slog.Int("status_code", statusCode), + slog.String("method", method), + slog.String("path", c.Path()), + slog.String("match", match), + slog.String("query", string(queryString)), + slog.String("error", err.Error()), + ) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(statusCode).SendString(err.Error()) + } + + content, err := r.supplements.TemplateManager.Render(method+" "+match, defaultMap) + if err != nil { + slog.Error("failed to generate div", + slog.String("method", method), + slog.String("path", c.Path()), + slog.String("match", match), + slog.String("query", string(queryString)), + slog.String("error", err.Error()), + ) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + if val, ok := defaultMap["Output"]; ok && len(content) == 0 { + content = val.([]byte) + } + + if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled { + go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) + } + + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(statusCode).Type("html").Send(content) + }) + } + } + + segments := []string{"header", "body", "footer", "top-embeds", "bottom-embeds"} + + r.app.Get("/api/v1/general-page/:part", func(c *fiber.Ctx) error { + part := c.Params("part") + if !slices.Contains(segments, part) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("unknown segment '%s'", part)) + } + err := r.generalPageSegment(c, part) + return err + }) + + for _, segment := range segments { + r.supplements.TemplateManager.Add("general-page-"+segment, "views/partials/general-page-"+segment+".html") + } + + r.app.Static("/", "./static") + + Routes = make([]Route, 0) + + return nil +} + +func (r *Router) Listen(endpoint string) error { + if err := r.app.Listen(endpoint); err != nil { + return fmt.Errorf("error while running fiber server: %w", err) + } + return nil +} + +func (r *Router) Close() (err error) { + allErrors := make([]error, 0) + if err = r.supplements.BlogTrigger.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("fail to shutdown blog trigger scheduler: %w", err)) + } + if err = r.app.Shutdown(); err != nil { + allErrors = append(allErrors, fmt.Errorf("fail to shutdown fiber server: %w", err)) + } + if err = r.supplements.ClientCache.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("fail to dump cache: %w", err)) + } + if err = r.supplements.DB.Close(); err != nil { + allErrors = append(allErrors, fmt.Errorf("fail to close db connection: %w", err)) + } + r.supplements.PageCache.Close() + return errors.Join(allErrors...) +} + +func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + path := c.Path() + method := c.Method() + trimmedPath := strings.Trim(path, "/") + cacheKey := "" + queryString := c.Request().URI().QueryString() + + switch route.ToCache() { + case ByUrlOnly: + cacheKey = fmt.Sprintf("%s.general-page.%s", method, trimmedPath) + case ByUrlAndQuery: + cacheKey = fmt.Sprintf("%s.general-page.%s.%s", method, trimmedPath, queryString) + } + + if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + content, err := r.supplements.TemplateManager.Render("general-page", fiber.Map{ + "L": r.supplements.Localization[lang], + "Lang": lang, + "QueryString": queryString, + }) + if err != nil { + slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + + go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) +} + +func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + path, pathParts, queryString, err := GetPathFromReferer(c) + if err != nil { + return err + } + + method := c.Method() + pattern, _, matched := r.templatedPathMatcher.MatchPath(method, path) + if !matched { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("fail to get templated page for '%s %s': not found", method, path)) + } + route := r.templatedRoutes[method][pattern] + + lang := "" + + switch route.ToValidateLang() { + case InPath, InReferer: + if len(pathParts) > 0 { + lang = pathParts[0] + } + if _, err := r.getAndValidateLang(c, NotRequired, lang); err != nil { + return err + } + case InForm: + if lang, err = r.getAndValidateLang(c, InForm); err != nil { + return err + } + } + + cacheKey := "" + trimmedPath := strings.Trim(path, "/") + switch route.ToCache() { + case ByUrlOnly: + cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath) + case ByUrlAndQuery: + cacheKey = fmt.Sprintf("%s.%s.%s.%s", method, part, trimmedPath, queryString) + } + + if route.ToCache() != Disabled { + if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + } + + var statusCode int + defaultMap := fiber.Map{ + "L": r.supplements.Localization[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": queryString, + } + + switch part { + case "body": + statusCode, err = route.RenderBody(c, r.supplements, lang, defaultMap) + case "header": + statusCode, err = route.RenderHeader(c, r.supplements, lang, defaultMap) + case "footer": + statusCode, err = route.RenderFooter(c, r.supplements, lang, defaultMap) + case "top-embeds": + statusCode, err = route.RenderTopEmbeds(c, r.supplements, lang, defaultMap) + case "bottom-embeds": + statusCode, err = route.RenderBottomEmbeds(c, r.supplements, lang, defaultMap) + } + if err != nil { + slog.Error(err.Error(), + slog.String("method", method), + slog.String("path", path), + slog.String("query", string(queryString)), + slog.String("segment", part), + slog.String("error", err.Error()), + ) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(statusCode).SendString(err.Error()) + } + + content, err := r.supplements.TemplateManager.Render("general-page-"+part, defaultMap, route.TemplatesToInject()...) + if err != nil { + slog.Error("failed to generate div", + slog.String("method", method), + slog.String("path", path), + slog.String("query", string(queryString)), + slog.String("segment", part), + slog.String("error", err.Error()), + ) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + + if statusCode == fiber.StatusNoContent && len(content) != 0 { + statusCode = fiber.StatusOK + } + + if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled { + go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) + } + + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(statusCode).Type("html").Send(content) +} + +func (r *Router) getAndValidateLang(c *fiber.Ctx, langSetting LangSetting, defaultLang ...string) (string, error) { + var lang string + if len(defaultLang) > 0 { + lang = defaultLang[0] + } + + switch langSetting { + case NotRequired: + return lang, nil + + case InPath: + path := c.Path() + + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} + } + if len(pathParts) > 0 && len(pathParts[0]) == 2 { + lang = pathParts[0] + } + + case InForm: + lang = c.FormValue("lang") + + case InReferer: + _, pathParts, _, err := GetPathFromReferer(c) + if err != nil { + return "", err + } + + if len(pathParts) >= 1 { + lang = pathParts[0] + } + } + + for _, availableLang := range r.supplements.AvailableLanguages { + if availableLang.Name == lang { + return lang, nil + } + } + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("lang value is invalid: '%s' is not considered an available language", lang)) +} + +func GetPathFromReferer(c *fiber.Ctx) (path string, pathParts []string, queryString string, err error) { + referer := c.Get("Referer") + if referer == "" { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path = urlStruct.EscapedPath() + pathParts = strings.Split(strings.Trim(path, "/"), "/") + queryString = urlStruct.RawQuery + return +} diff --git a/internal/tailwind/link_renderer.go b/internal/tailwind/link_renderer.go new file mode 100644 index 0000000..ff3d437 --- /dev/null +++ b/internal/tailwind/link_renderer.go @@ -0,0 +1,49 @@ +package tailwind + +import ( + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +type CustomLinkRenderer struct { + html.Config +} + +func NewCustomLinkRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &CustomLinkRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +func (r *CustomLinkRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.Link) + if entering { + _, _ = w.WriteString("<a href=\"") + if r.Unsafe || !html.IsDangerousURL(n.Destination) { + _, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true))) + } + _, _ = w.WriteString(`"`) + if n.Title != nil { + _, _ = w.WriteString(` title="`) + _, _ = w.Write(util.EscapeHTML(n.Title)) + _, _ = w.WriteString(`"`) + } + if n.Attributes() != nil { + html.RenderAttributes(w, n, nil) + } + _, _ = w.WriteString(">") + } else { + _, _ = w.WriteString("</a>") + } + return ast.WalkContinue, nil +} + +func (r *CustomLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindLink, r.renderLink) +} diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go index 2f052b9..c53ded1 100644 --- a/internal/tailwind/transformer.go +++ b/internal/tailwind/transformer.go @@ -1,6 +1,9 @@ package tailwind import ( + "bytes" + "fmt" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" @@ -17,44 +20,48 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, switch node := n.(type) { case *ast.Heading: classes := map[int]string{ - 1: "font-patua text-[2.5vmax] font-bold text-main-dark mb-[1.2vmin] tracking-[0.04vmin]", - 2: "font-spectral text-[2vmax] font-bold text-main-dark mb-[0.8vmin] tracking-[0.04vmin]", - 3: "font-spectral text-[1.5vmax] font-medium text-main-dark mb-[0.6vmin] tracking-[0.04vmin]", - 4: "font-spectral text-[1.2vmax] font-medium text-main-medium mb-[0.4vmin] tracking-[0.04vmin]", - 5: "font-spectral text-[1vmax] font-medium text-main-medium mb-[0.4vmin] italic tracking-[0.04vmin]", - 6: "font-spectral text-[1vmax] font-medium text-secondary mb-[0.4vmin]", + 1: "font-andika text-4xl font-bold text-main-hard mb-3 tracking-[.0125rem]", + 2: "font-andika text-3xl font-bold text-main-hard mb-1 tracking-[.0125rem]", + 3: "font-andika text-2xl font-medium text-main-hard mb-0.8 tracking-[.0125rem]", + 4: "font-andika text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]", + 5: "font-andika text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]", + 6: "font-andika text-base font-medium text-secondary mb-0.5", } if class, ok := classes[node.Level]; ok { node.SetAttribute([]byte("class"), []byte(class)) } case *ast.Paragraph: - node.SetAttribute([]byte("class"), []byte("text-[1vmax]/[2] font-spectral tracking-[0.04vmin] -indent-[2vmax] ml-[2vmax] mb-[1.6vmin]")) + node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem] -indent-8 ml-4 mb-8")) case *ast.List: if node.IsOrdered() { - node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]")) + node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-2 mb-4 pl-8")) } else { - node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]")) + node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-2 mb-4 pl-8")) } case *ast.ListItem: - node.SetAttribute([]byte("class"), []byte("text-[1vmax]/[2] font-spectral tracking-[0.04vmin]")) + node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem]")) case *ast.Blockquote: - node.SetAttribute([]byte("class"), []byte("border-l-[0.4vmin] border-main-medium bg-background-dark p-[0.8vmin] mb-[0.8vmin] italic")) + node.SetAttribute([]byte("class"), []byte("border-l-2 border-main-medium bg-background-dark p-1 mb-2 italic")) case *ast.CodeSpan: node.SetAttribute([]byte("class"), []byte("bg-background-dark")) case *ast.CodeBlock, *ast.FencedCodeBlock: - node.SetAttribute([]byte("class"), []byte("bg-background-dark p-[0.8vmin] rounded-lg overflow-x-auto mb-[1.6vmin]")) + node.SetAttribute([]byte("class"), []byte("bg-background-dark p-1 rounded-lg overflow-x-auto mb-4")) case *ast.Link: - node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline")) + if bytes.HasPrefix(node.Destination, []byte{'.', '/'}) { + onclickAttr := fmt.Appendf(make([]byte, 0, 21+len(node.Destination)), "return changeUrl('%s');", node.Destination) + node.SetAttribute([]byte("onclick"), onclickAttr) + } + node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-hard underline")) case *ast.Image: - node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-[1.6vmin]")) + node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-4")) case *ast.Emphasis: switch node.Level { diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go index 5effcb3..75309e4 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -22,22 +22,39 @@ type TemplateManagerTemplates struct { Files []string } -func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, error) { +var templateFuncMap = template.FuncMap{ + "contains": strings.Contains, + "iterate": func(count uint) []uint { + items := make([]uint, count) + for i := range count { + items[i] = i + } + return items + }, + "replace": strings.ReplaceAll, +} + +func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { templateMap := make(map[string]templateManagerRender) for _, tmplStruct := range templates { - tmpl := template.New("").Funcs(template.FuncMap{ - "contains": strings.Contains, - }) + 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, } - } return &TemplateManager{ @@ -45,13 +62,59 @@ func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, }, nil } -func (tm *TemplateManager) Render(name string, data interface{}) ([]byte, error) { +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() + if err != nil { + return nil, fmt.Errorf("couldn't clone existing template for rendering: %w", err) + } + tempTmpl, err = tempTmpl.ParseFiles(files...) + 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 := tmpl.Tmpl.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 { + tm.templates[name] = templateManagerRender{ + Main: "", + Tmpl: tmpl, + } + return nil + } + + tmpl, err := tmpl.ParseFiles(files...) + if err != nil { + return fmt.Errorf("failed to add template into manager: %w", err) + } + + tm.templates[name] = templateManagerRender{ + Main: filepath.Base(files[0]), + Tmpl: tmpl, + } + + return nil +} diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index f5ee6c9..c7f2df8 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -9,3 +9,66 @@ BlogSearch: ChooseAllTags: 'Choose All' GlobalMap: Header: 'Global Map' +HomePage: + Header: 'Home Page' + Contacts: 'Contacts' + DidYouKnowThat: 'Did you know, that...' + SidebarDescription: 'Describing the Sidebar' + HomePageDescription: 'Home Page -- where we are right now!' + BlogSearchDescription: 'Blog Search. You can find my travel notes there and, maybe, something else!' + MarkerMapDescription: 'Map with markers of my travel notes -- a convinient way to find an interesting region and find related posts!' + ThemeSwitchDescription: 'Theme Palette lets you change the theme of the site -- there are 2 dark and 2 bright themes currently!' + Hymn1: 'We welcome you to Sayasite!' + Hymn2: "We've been expecting you!" + Hymn3: 'You bring such joy in Sayasite!' + Hymn4: 'No matter where you roam, know our love is true!~' +UnsubscribePage: + Header: 'Cancelling the Subscription' + UnsetCode: 'Your cancel code is not set. You just having a walk around the site?' + InvalidCode: "Your cancel code could not be verified. Maybe, the code expired after a day. Or maybe, you've already unsubscribed." + OnServerError: "We couldn't unsubscribe you, the problem is on our side! We will solve this problem as soon as possible." + Success: 'Cancelled your subscription successfully! We do hope we will engage you again someday.' +Metadata: + Published: 'Published' + Action: 'Took place on' +Mail: + UnsubscribeFooter: 'If this letter got you in a bad mood, you can unsubscribe from my blog by {}this link{/}.' + VerifyEmail: + Subject: 'Verification Link for SAYA.TODAY is waiting for you!' + Welcome: 'Greetings!' + Intro: 'There are 2 ways for us to validate your profile:' + GotoLink: 'Go by this link:' + InputCode: "Or input this code on the personal page:" + IfRandom: 'If you are unsure what this letter this, you can ignore it, and also, if you are interested, you may visit my site :)' + NewPost: + Subject: 'A new post arrived at SAYA.TODAY!' + Intro: 'A new post came!' + CapturedOn: 'Captured on' +UserProfile: + Header: 'Personal Settings' + EmailHeader: 'E-Mail' + VerificationCodeHeader: 'Verification Code' + TypeEmail: 'Please type your email' + SendCodeButton: 'Send Code' + VerifyButton: 'Verify' + VerificationCodeSent: 'Verification code sent! Please check your e-mail. Your next verification attempt will be possible on {}.' + DelayTilVerification: 'You have already requested the verification before! Please wait until {} for a new verification request.' + FailedEmailRender: 'Failed to render the email status message, please ask administrator for a more detailed cause.' + VerificationCodeSendingError: 'Failed to send a verification code, please ask administrator for a more detailed cause.' + EmailAlreadyValidated: 'You already have this E-Mail validated.' + EmailTaken: 'This E-Mail is already taken.' + EmailEmpty: 'E-Mail is unset or empty.' + VerificationSuccess: 'Success! You are now able to subscribe with your new e-mail.' + VerificationFailed: 'Verification code is invalid, please ask administrator for a more detailed cause.' + VerificationEmpty: 'Verification code is unset or empty.' + SubscriptionHeader: 'Subscribe to...' + TagsNone: 'None' + TagsAll: 'All' + TagsSpecific: 'Specific' + TagDoesNotExist: 'The tag "{}" does not exist on the site.' + TagAlreadyAdded: 'The tag "{}" is already in your subscribed list.' + SaveButton: 'Save' + SubscribeInvalidType: 'Invalid subscription type.' + FailedToSubscribe: 'Failed to subscribe by new settings, please ask administrator for a more detailed cause.' + SubscribedSuccessfully: 'Successfully subscribed you by new settings!' + RefreshPage: 'Please refresh the profile page on the device, where you requested the code.' diff --git a/locale/localization.go b/locale/localization.go index 56b6462..c6467c4 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -8,9 +8,14 @@ import ( ) type LocaleConfig struct { - TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` - BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` - GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` + TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` + BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` + GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` + HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"` + UnsubscribePage UnsubscribePageConfig `yaml:"UnsubscribePage" json:"UnsubscribePage"` + Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"` + Mail MailConfig `yaml:"Mail" json:"Mail"` + UserProfile UserProfileConfig `yaml:"UserProfile" json:"UserProfile"` } type BlogSearchConfig struct { @@ -27,6 +32,85 @@ type GlobalMapConfig struct { Header string `yaml:"Header" json:"Header"` } +type HomePageConfig struct { + Header string `yaml:"Header" json:"Header"` + Contacts string `yaml:"Contacts" json:"Contacts"` + DidYouKnowThat string `yaml:"DidYouKnowThat" json:"DidYouKnowThat"` + SidebarDescription string `yaml:"SidebarDescription" json:"SidebarDescription"` + HomePageDescription string `yaml:"HomePageDescription" json:"HomePageDescription"` + BlogSearchDescription string `yaml:"BlogSearchDescription" json:"BlogSearchDescription"` + MarkerMapDescription string `yaml:"MarkerMapDescription" json:"MarkerMapDescription"` + ThemeSwitchDescription string `yaml:"ThemeSwitchDescription" json:"ThemeSwitchDescription"` + Hymn1 string `yaml:"Hymn1" json:"Hymn1"` + Hymn2 string `yaml:"Hymn2" json:"Hymn2"` + Hymn3 string `yaml:"Hymn3" json:"Hymn3"` + Hymn4 string `yaml:"Hymn4" json:"Hymn4"` +} + +type UnsubscribePageConfig struct { + Header string `yaml:"Header" json:"Header"` + UnsetCode string `yaml:"UnsetCode" json:"UnsetCode"` + InvalidCode string `yaml:"InvalidCode" json:"InvalidCode"` + OnServerError string `yaml:"OnServerError" json:"OnServerError"` + Success string `yaml:"Success" json:"Success"` +} + +type MetadataConfig struct { + Action string `yaml:"Action" json:"Action"` + Published string `yaml:"Published" json:"Published"` +} + +type MailConfig struct { + UnsubscribeFooter string `yaml:"UnsubscribeFooter" json:"UnsubscribeFooter"` + VerifyEmail VerifyEmailConfig `yaml:"VerifyEmail" json:"VerifyEmail"` + NewPost NewPostConfig `yaml:"NewPost" json:"NewPost"` +} + +type VerifyEmailConfig struct { + Subject string `yaml:"Subject" json:"Subject"` + Welcome string `yaml:"Welcome" json:"Welcome"` + Intro string `yaml:"Intro" json:"Intro"` + GotoLink string `yaml:"GotoLink" json:"GotoLink"` + InputCode string `yaml:"InputCode" json:"InputCode"` + IfRandom string `yaml:"IfRandom" json:"IfRandom"` +} + +type NewPostConfig struct { + Subject string `yaml:"Subject" json:"Subject"` + CapturedOn string `yaml:"CapturedOn" json:"CapturedOn"` + Intro string `yaml:"Intro" json:"Intro"` +} + +type UserProfileConfig struct { + Header string `yaml:"Header" json:"Header"` + EmailHeader string `yaml:"EmailHeader" json:"EmailHeader"` + VerificationCodeHeader string `yaml:"VerificationCodeHeader" json:"VerificationCodeHeader"` + TypeEmail string `yaml:"TypeEmail" json:"TypeEmail"` + SendCodeButton string `yaml:"SendCodeButton" json:"SendCodeButton"` + VerifyButton string `yaml:"VerifyButton" json:"VerifyButton"` + VerificationCodeSent string `yaml:"VerificationCodeSent" json:"VerificationCodeSent"` + DelayTilVerification string `yaml:"DelayTilVerification" json:"DelayTilVerification"` + FailedEmailRender string `yaml:"FailedEmailRender" json:"FailedEmailRender"` + VerificationCodeSendingError string `yaml:"VerificationCodeSendingError" json:"VerificationCodeSendingError"` + EmailAlreadyValidated string `yaml:"EmailAlreadyValidated" json:"EmailAlreadyValidated"` + EmailTaken string `yaml:"EmailTaken" json:"EmailTaken"` + EmailEmpty string `yaml:"EmailEmpty" json:"EmailEmpty"` + VerificationSuccess string `yaml:"VerificationSuccess" json:"VerificationSuccess"` + VerificationFailed string `yaml:"VerificationFailed" json:"VerificationFailed"` + VerificationEmpty string `yaml:"VerificationEmpty" json:"VerificationEmpty"` + SubscriptionHeader string `yaml:"SubscriptionHeader" json:"SubscriptionHeader"` + TagsNone string `yaml:"TagsNone" json:"TagsNone"` + TagsAll string `yaml:"TagsAll" json:"TagsAll"` + TagsSpecific string `yaml:"TagsSpecific" json:"TagsSpecific"` + TagDoesNotExist string `yaml:"TagDoesNotExist" json:"TagDoesNotExist"` + TagAlreadyAdded string `yaml:"TagAlreadyAdded" json:"TagAlreadyAdded"` + SaveButton string `yaml:"SaveButton" json:"SaveButton"` + SubscribeInvalidType string `yaml:"SubscribeInvalidType" json:"SubscribeInvalidType"` + FailedToSubscribe string `yaml:"FailedToSubscribe" json:"FailedToSubscribe"` + SubscribedSuccessfully string `yaml:"SubscribedSuccessfully" json:"SubscribedSuccessfully"` + RefreshPage string `yaml:"RefreshPage" json:"RefreshPage"` +} + func LoadConfig(path string, config *LocaleConfig) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index c236d1e..5df82da 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -9,3 +9,66 @@ BlogSearch: ChooseAllTags: 'Выбрать все' GlobalMap: Header: 'Глобальная карта' +HomePage: + Header: 'Домашняя страница' + Contacts: 'Контакты' + DidYouKnowThat: 'А вы знали, что...' + SidebarDescription: 'Поясняем за сайдбар' + HomePageDescription: 'Домашняя страница -- где мы сейчас и находимся!' + BlogSearchDescription: 'Поиск по блогу. Там размещены мои путевые заметки и, может, что-то ещё!' + MarkerMapDescription: 'Карта с маркерами моих путевых заметок, -- удобный способ найти интересный регион и перейти на заинтересовавшие посты!' + ThemeSwitchDescription: 'Позволяет поменять тему сайта -- есть 2 светлые и 2 тёмные темы!' + Hymn1: 'Приветствуем Вас на Саясайте!' + Hymn2: 'Мы Вас столько ждали!' + Hymn3: 'Столько радости от Вас на Саясайте!' + Hymn4: 'Где бы Вы не были, знайте, -- наша любовь крепка!' +UnsubscribePage: + Header: 'Отписка' + UnsetCode: 'Код для отписки не проставлен. Вы случайно не заблудились?' + InvalidCode: 'Код для отписки не подтверждён. Может, истекло время в сутки для кода. А может, вы уже успели отписаться.' + OnServerError: 'Мы не смогли вас отписать, проблема на нашей стороне! В скором времени займёмся проблемой.' + Success: 'Вы отписались от рассылки! Надеюсь, мы ещё сможем вас заинтересовать.' +Metadata: + Published: 'Опубликовано' + Action: 'Время действия' +Mail: + UnsubscribeFooter: 'Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по {}этой ссылке{/}.' + VerifyEmail: + Subject: 'Ссылка для верификации для подтверждения вашей почты SAYA.TODAY' + Welcome: 'Приветствую!' + Intro: 'Есть 2 способа, чтобы подтвердить свою электронную почту:' + GotoLink: 'Перейти по этой ссылке:' + InputCode: 'Либо ввести этот код на странице подписок:' + IfRandom: 'Если ты не понимаешь, что это за спам, можешь проигнорировать это письмо, а ещё, если интересно, можешь посетить мой сайт :)' + NewPost: + Subject: 'Новый пост на SAYA.TODAY!' + Intro: 'Вышел новый пост!' + CapturedOn: 'Снималось' +UserProfile: + Header: 'Личные настройки' + EmailHeader: 'E-Mail' + VerificationCodeHeader: 'Код верификации' + TypeEmail: 'Пожалуйста, введите свою почту' + SendCodeButton: 'Отправить код' + VerifyButton: 'Верифицировать' + VerificationCodeSent: 'Выслали вам код верификации! Проверьте свою почту. Следующая попытка верификации будет возможна {}.' + DelayTilVerification: 'Вы уже запрашивали недавно верификацию! Дождитесь {} для новой попытки.' + FailedEmailRender: 'Неудачная попытка создать электронное письмо, более подробно спрашивайте у администратора.' + VerificationCodeSendingError: 'Неудачная попытка прислать код для верификации, более подробно спрашивайте у администратора.' + EmailAlreadyValidated: 'Эту почту вы уже подтверждали.' + EmailTaken: 'Эта почта уже занята.' + EmailEmpty: 'Почта не введена.' + VerificationSuccess: 'Поздравляем! Теперь по этой почте можно подписаться на блог.' + VerificationFailed: 'Код для верификации невалиден, при вопросах, пожалуйста, обращайтесь к администратору.' + VerificationEmpty: 'Код для верификации не введён.' + SubscriptionHeader: 'Подписаться на...' + TagsNone: 'Ничего' + TagsAll: 'Всё' + TagsSpecific: 'Определённое' + TagDoesNotExist: 'Тэга "{}" нет на сайте.' + TagAlreadyAdded: 'Тэг "{}" уже у вас добавлен.' + SaveButton: 'Сохранить' + SubscribeInvalidType: 'Неизвестный тип подписки.' + FailedToSubscribe: 'Неудачная попытка записать новую подписку, более подробно спрашивайте у администратора.' + SubscribedSuccessfully: 'Вы успешно подписались по новым настройкам!' + RefreshPage: 'Пожалуйста, обновите страницу с профилем на устройстве, откуда запрашивали код.' @@ -2,41 +2,21 @@ package main import ( "flag" - "log" "log/slog" "os" + "os/signal" + "syscall" "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/internal/lightgallery" "github.com/SayaAndy/saya-today-web/internal/router" - "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/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/redirect" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" - gmhtml "github.com/yuin/goldmark/renderer/html" + + _ "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( - lightgallery.NewLightGalleryExtension(), - tailwind.NewTailwindExtension(), - ), - goldmark.WithParserOptions( - parser.WithAutoHeadingID(), - ), - goldmark.WithRendererOptions( - gmhtml.WithXHTML(), - ), - ) - b2Client *b2.B2Client - configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)") - availableLanguages = make([]string, 0) - localization = make(map[string]*locale.LocaleConfig) + configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)") ) func main() { @@ -53,52 +33,39 @@ func main() { slog.SetLogLoggerLevel(cfg.LogLevel) slog.Info("starting sayana-web server...") - b2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config) + app, err := router.NewRouter(cfg) if err != nil { - slog.Error("fail to initialize b2 client", slog.String("error", err.Error())) + slog.Error("fail to initialize router app", 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.Name) - localization[lang.Name] = localeCfg - } - - tm, err := templatemanager.NewTemplateManager([]templatemanager.TemplateManagerTemplates{ - {Name: "blog-page", Files: []string{"views/layouts/general-page.html", "views/pages/blog-page.html"}}, - {Name: "blog-catalogue", Files: []string{"views/layouts/general-page.html", "views/pages/blog-catalogue.html"}}, - {Name: "index", Files: []string{"views/index.html"}}, - {Name: "catalogue-blog-cards", Files: []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}}, - {Name: "global-map", Files: []string{"views/pages/global-map.html"}}, - }) - if err != nil { - slog.Error("fail to initialize template manager", 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) } - app := fiber.New(fiber.Config{}) + routerShutdown := make(chan struct{}, 1) - app.Use(redirect.New(redirect.Config{ - Rules: map[string]string{ - "/ru": "/", - "/en": "/", - }, - StatusCode: 301, - })) - - app.Get("/", router.Root(tm, cfg.AvailableLanguages)) - app.Get("/:lang/map", router.Lang_Map(tm, localization, availableLanguages, b2Client)) - app.Get("/:lang/blog", router.Lang_Blog(tm, localization, availableLanguages, b2Client)) - app.Get("/:lang/blog/:title", router.Lang_Blog_Title(tm, localization, availableLanguages, b2Client, md)) - app.Get("/api/v1/tz", router.Api_V1_TZ()) - app.Get("/api/v1/blog-search", router.Api_V1_BlogSearch(tm, localization, availableLanguages, b2Client)) + go func() { + if err := app.Listen(":3000"); err != nil { + slog.Error("error while running fiber server", slog.String("error", err.Error())) + if nestedErr := app.Close(); nestedErr != nil { + slog.Error("error while shutting down fiber server", slog.String("error", err.Error())) + } + <-routerShutdown + } + }() - app.Static("/", "./static") + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - log.Fatal(app.Listen(":3000")) + 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...") + } } diff --git a/migrations/1_create_stats_tables.down.sql b/migrations/1_create_stats_tables.down.sql new file mode 100644 index 0000000..ee4593c --- /dev/null +++ b/migrations/1_create_stats_tables.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS blog_views; +DROP TABLE IF EXISTS blog_likes; diff --git a/migrations/1_create_stats_tables.up.sql b/migrations/1_create_stats_tables.up.sql new file mode 100644 index 0000000..8852268 --- /dev/null +++ b/migrations/1_create_stats_tables.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS blog_likes ( + page_ref VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + PRIMARY KEY (page_ref, user_id) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS blog_views ( + page_ref VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + PRIMARY KEY (page_ref, user_id) +) WITHOUT ROWID; diff --git a/migrations/2_create_subscription_tables.down.sql b/migrations/2_create_subscription_tables.down.sql new file mode 100644 index 0000000..d9a369e --- /dev/null +++ b/migrations/2_create_subscription_tables.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS user_email_table_email_uindex; +DROP TABLE IF EXISTS user_email_table; +DROP TABLE IF EXISTS subscription_user_to_tags_table; +DROP TABLE IF EXISTS subscription_tag_to_users_table; diff --git a/migrations/2_create_subscription_tables.up.sql b/migrations/2_create_subscription_tables.up.sql new file mode 100644 index 0000000..e6e905a --- /dev/null +++ b/migrations/2_create_subscription_tables.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS user_email_table ( + user_id VARCHAR(32) NOT NULL PRIMARY KEY, + email VARCHAR(64) NOT NULL, + lang VARCHAR(2) NOT NULL +) WITHOUT ROWID; + +CREATE UNIQUE INDEX user_email_table_email_uindex +ON user_email_table(email); + +CREATE TABLE IF NOT EXISTS subscription_user_to_tags_table ( + user_id VARCHAR(32) NOT NULL PRIMARY KEY, + tags TEXT NOT NULL +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS subscription_tag_to_users_table ( + tag VARCHAR(32) NOT NULL PRIMARY KEY, + user_ids TEXT NOT NULL +) WITHOUT ROWID; diff --git a/static/favicon.ico b/static/favicon.ico Binary files differnew file mode 100644 index 0000000..33eb226 --- /dev/null +++ b/static/favicon.ico diff --git a/static/input.css b/static/input.css index 8c0069b..739caa3 100644 --- a/static/input.css +++ b/static/input.css @@ -1,25 +1,66 @@ +@import url('https://fonts.googleapis.com/css2?family=Andika:ital,wght@0,400;0,700;1,400;1,700&display=swap'); + @import "tailwindcss"; @theme { - --font-spectral: Spectral, serif; - --font-patua: "Patua One", cursive; + --font-andika: "Andika", sans-serif; + --breakpoint-xs: 32rem; +} + +html { + font-size: 12px; + --crossed-out: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='30' height='30' patternTransform='scale(1) rotate(65)'><rect x='0' y='0' width='100%' height='100%' fill='%232b2b3100' /><path d='M30 15L22.5 30 15 15 22.5 0zm-15 0L7.5 30 0 15 7.5 0z' stroke-width='0.6' stroke='%23ffffffff' fill='none' /></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)' /></svg>"); + --noise-image: url(data:image/png;base64,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); + --split-left-background: unset; + --split-left-repeat: no-repeat; + --split-left-size: cover; + --split-left-position: left; + --split-right-background: unset; + --split-right-repeat: no-repeat; + --split-right-size: cover; + --split-right-position: left; +} + +@media screen and (min-width: 640px) and (min-height: 640px) { + html { + font-size: 14px; + } +} + +@media screen and (min-width: 900px) and (min-height: 900px) { + html { + font-size: 16px; + } +} + +@media screen and (min-width: 1400px) and (min-height: 1400px) { + html { + font-size: 20px; + } +} + +@media screen and (min-width: 1920px) and (min-height: 1920px) { + html { + font-size: 24px; + } } [data-theme="olive"] { - --main-dark-color: #4b513a; + --main-hard-color: #4b513a; --main-medium-color: #8a9d8a; - --main-light-color: #a5b8a5; + --main-soft-color: #a5b8a5; --background-dark-color: #ffffdd; + --background-medium-color: var(--background-dark-color); --background-light-color: #fffff0; --sidebar-text-color: var(--background-dark-color); - --sidebar-stroke-color: var(--main-dark-color); - --secondary-color: var(--main-light-color); - --leaflet-text-color: var(--main-dark-color); + --sidebar-stroke-color: var(--main-hard-color); + --secondary-color: var(--main-soft-color); + --leaflet-text-color: var(--main-hard-color); --leaflet-link-color: var(--main-medium-color); - --leaflet-link-hover-color: var(--main-light-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%23ffffdb%22%2F%3E%3Cpath%20fill%3D%22%23777e63%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%23a0a182%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: 10vh; + --interlocked-hexagons-background-size: 10dvh; --squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%234a5139%22%2F%3E%3Cpath%20fill%3D%22%23b2b285%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; @@ -27,20 +68,21 @@ } [data-theme="lettuce"] { - --main-dark-color: #384a0c; + --main-hard-color: #384a0c; --main-medium-color: #9bb665; - --main-light-color: #bbd095; + --main-soft-color: #bbd095; --background-dark-color: #f5eee2; + --background-medium-color: var(--background-dark-color); --background-light-color: #fff9ef; - --sidebar-text-color: var(--main-dark-color); + --sidebar-text-color: var(--main-hard-color); --sidebar-stroke-color: var(--background-dark-color); --secondary-color: var(--main-medium-color); - --leaflet-text-color: var(--main-dark-color); + --leaflet-text-color: var(--main-hard-color); --leaflet-link-color: var(--main-medium-color); - --leaflet-link-hover-color: var(--main-light-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: 10vh; + --interlocked-hexagons-background-size: 10dvh; --squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23bbd095%22%2F%3E%3Cpath%20fill%3D%22%23f5ede1%22%20d%3D%22M0%200v6.77L6.72%200zm8.91%200L0%208.96v3.86L12.83%200zm6.11%200L0%2015.06v3.87L18.9%200zm6.11%200L0%2021.12v3.92L25%200zM25%200l25%2024.99v-3.87L28.87%200zm25%2024.99%2025%2024.98L100%2025%2075%200zm-50%20.05v3.87l21.13%2021.12H25l-25-25zm25%2024.99h3.87L50%2028.9v-3.87zm0%200L0%2075l25%2025%2025-24.99zM50%2075l25-24.98h-3.87L50%2071.14v3.87zm0%200v3.87L71.13%20100H75L50%2075.01zm25%2025h3.87L100%2078.88v-3.87zm25-24.99v-3.87L78.87%2050.03H75L100%2075zM31.1%200%2050%2018.93v-3.87L34.98%200h-3.87zm6.07%200L50%2012.82V8.96L41.04%200zm6.1%200L50%206.77V0h-6.72zM25%202.19%202.14%2025.04H25zm0%2022.85v22.8l22.81-22.8zM75%203.92l21.13%2021.12L75%2046.16%2053.87%2025.04zm0%202.19L56.06%2025.04l18.89%2018.88%2018.94-18.88L75%206.1zm0%203.92%2015.07%2015L75%2040.06%2059.98%2025.04zm0%202.18L62.17%2025.04%2075%2037.86l12.83-12.82L75%2012.2zm0%203.82%208.96%209L75%2033.95%2066.04%2025zm0%202.24-6.72%206.77L75%2031.76l6.77-6.72h-.05zM0%2031.09v3.87l15.02%2015.07h3.87zm50%200L31.1%2050.03h3.88L50%2034.96V31.1zM0%2037.2v3.87l8.91%208.96h3.92zm50%200L37.17%2050.03h3.87L50%2041.07zM0%2043.3v6.73h6.72zm50%200-6.72%206.73H50zm0%206.73v6.71l6.72-6.71zm8.96%200L50%2058.93v3.92l12.83-12.82zm6.06%200L50%2065.03v3.88l18.94-18.88zm16.09%200L100%2068.9v-3.87l-15.02-15zm6.11%200L100%2062.85v-3.92l-8.91-8.9zm6.06%200%206.72%206.71v-6.71zM75%2052.2%2052.19%2075H75zM75%2075v22.8L97.81%2075zM25%2053.9l21.13%2021.12L25%2096.13%203.87%2075.01zm0%202.2L6.1%2074.95l18.95%2018.88%2018.9-18.88L25%2056.08zm0%203.9%2015.02%2015.01L25%2090.03%209.98%2075zm0%202.19L12.17%2075%2025%2087.84%2037.83%2075%2025%2062.2zm0%203.87L33.96%2075%2025%2083.97%2016.04%2075zm0%202.23-6.72%206.72L25%2081.73l6.72-6.72L25%2068.3zm25%2012.83v3.87L65.02%20100h3.87zm50%200L81.1%20100h3.88L100%2084.99zm-50%206.06v3.86l8.96%208.96h3.87zm50%200L87.22%20100h3.87l8.91-8.96zm-50%206.1V100h6.72zm50%200L93.28%20100H100z%22%2F%3E%3C%2Fsvg%3E"); --squares-and-triangles-background-repeat: repeat; --squares-and-triangles-background-width-coef: 10; @@ -48,20 +90,21 @@ } [data-theme="night"] { - --main-dark-color: var(--color-sky-100); + --main-hard-color: var(--color-sky-100); --main-medium-color: var(--color-sky-200); - --main-light-color: var(--color-sky-300); + --main-soft-color: var(--color-sky-300); --background-dark-color: #13131b; + --background-medium-color: var(--background-dark-color); --background-light-color: #222230; - --sidebar-text-color: var(--main-dark-color); + --sidebar-text-color: var(--main-hard-color); --sidebar-stroke-color: var(--background-light-color); - --secondary-color: var(--main-light-color); + --secondary-color: var(--main-soft-color); --leaflet-text-color: var(--background-dark-color); --leaflet-link-color: var(--background-dark-color); --leaflet-link-hover-color: var(--background-light-color); --interlocked-hexagons-background: radial-gradient(33.5% 25% at 100% 82%, rgba(190, 227, 248, 0.2) 0%, rgba(197, 179, 246, 0.13) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(54% 25% at 55% 100%, rgba(190, 227, 248, 0.2) 0%, rgba(197, 179, 246, 0.13) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(37% 19.5% at 20% 100%, rgba(176, 220, 248, 0.3) 0%, rgba(197, 172, 247, 0.2) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(63% 34.5% at 66% 77%, rgba(190, 227, 248, 0.15) 0%, rgba(197, 179, 246, 0.1) 33.33%, rgba(255, 0, 0, 0) 100%), linear-gradient(0deg, #2f5883 0%, #2f4e7a 4.75%, #2e4472 9.5%, #2a3065 19%, #272e57 26.5%, #242b4b 34%, #1f2437 49%, #1c2030 57.75%, #1a1b29 66.5%, #13131b 84%); --interlocked-hexagons-background-repeat: no-repeat; - --interlocked-hexagons-background-size: 100vw 100vh; + --interlocked-hexagons-background-size: 100dvw 100dvh; --squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2260%22%20height%3D%2260%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%2313131b%22%2F%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23363645%22%20stroke-width%3D%22.5%22%20d%3D%22M10%2050H0m60-40H50m0%2040v10M10%200v10m21%2030v20m0-60v20m9%2010h20M0%2030h20m0%2010h20V20H20v10zM10%2010h40v40H10zM0%200h60v60H0z%22%2F%3E%3C%2Fsvg%3E"); --squares-and-triangles-background-repeat: repeat; --squares-and-triangles-background-width-coef: 5; @@ -69,14 +112,15 @@ } [data-theme="ram"] { - --main-dark-color: #fefad8; + --main-hard-color: #fefad8; --main-medium-color: #f6e4c5; - --main-light-color: #e8d4b8; + --main-soft-color: #e8d4b8; --background-dark-color: #413b34; + --background-medium-color: #62584f; --background-light-color: #6d6258; - --sidebar-text-color: var(--main-dark-color); + --sidebar-text-color: var(--main-hard-color); --sidebar-stroke-color: var(--background-light-color); - --secondary-color: var(--main-light-color); + --secondary-color: var(--main-soft-color); --leaflet-text-color: var(--background-dark-color); --leaflet-link-color: var(--background-dark-color); --leaflet-link-hover-color: var(--background-light-color); @@ -89,13 +133,37 @@ --squares-and-triangles-background-height-coef: 15; } -a { +[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; + --sidebar-text-color: var(--main-hard-color); + --sidebar-stroke-color: var(--background-light-color); + --secondary-color: var(--main-medium-color); + --leaflet-text-color: var(--main-hard-color); + --leaflet-link-color: var(--main-medium-color); + --leaflet-link-hover-color: var(--main-soft-color); + --interlocked-hexagons-background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); + --interlocked-hexagons-background-repeat: repeat; + --interlocked-hexagons-background-size: auto; + --squares-and-triangles-background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); + --squares-and-triangles-background-repeat: repeat; + --squares-and-triangles-background-width-coef: 5; + --squares-and-triangles-background-height-coef: 5; + --split-left-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lilac/split-left.webp'); + --split-right-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lilac/split-right.webp'); +} + +a, .linklike { color: var(--main-medium-color) !important; text-decoration: underline; } -a:hover { - color: var(--main-light-color) !important; +a:hover, .linklike:hover { + color: var(--main-soft-color) !important; } #map-container { @@ -112,6 +180,27 @@ a:hover { text-decoration: underline; } +.crossable.disabled::before, +.crossable.htmx-request::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--main-hard-color); + background-size: 0.5rem 0.5rem; + mask-image: var(--crossed-out); + opacity: 1; + transition: opacity 0.3s; + z-index: 25; +} + +form.crossable.disabled input, +form.crossable.htmx-request input { + filter: grayscale(1); +} + .bg-interlocked-hexagons { background-image: var(--interlocked-hexagons-background); background-repeat: var(--interlocked-hexagons-background-repeat); @@ -123,6 +212,20 @@ a:hover { background-repeat: var(--squares-and-triangles-background-repeat); } +.bg-split-left { + background-image: var(--split-left-background); + background-repeat: var(--split-left-repeat); + background-size: var(--split-left-size); + background-position: var(--split-left-position); +} + +.bg-split-right { + background-image: var(--split-right-background); + background-repeat: var(--split-right-repeat); + background-size: var(--split-right-size); + background-position: var(--split-right-position); +} + .text-sidebar { color: var(--sidebar-text-color); } @@ -131,121 +234,115 @@ a:hover { border-color: var(--sidebar-stroke-color); } -.text-main-dark { - color: var(--main-dark-color); +.text-main-hard { + color: var(--main-hard-color); } .text-main-medium { color: var(--main-medium-color); } +.text-main-soft { + color: var(--main-soft-color); +} + .text-background-dark { color: var(--background-dark-color); } +.text-background-light { + color: var(--background-dark-color); +} + .text-secondary { color: var(--secondary-color); } -.bg-main-dark { - background-color: var(--main-dark-color); +.bg-main-hard { + background-color: var(--main-hard-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==); } -.bg-background-dark { - background-color: var(--background-dark-color); +.bg-main-medium { + background-color: var(--main-medium-color); + background-image: url(data:image/png;base64,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); } -.bg-background-light { - background-color: var(--background-light-color); +.bg-main-soft { + background-color: var(--main-soft-color); + background-image: url(data:image/png;base64,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); } -.border-main-dark { - border-color: var(--main-dark-color); +.bg-background-dark { + background-color: var(--background-dark-color); + background-image: url(data:image/png;base64,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); } -.border-main-medium { - border-color: var(--main-medium-color); +.bg-background-medium { + background-color: var(--background-medium-color); + background-image: var(--noise-image); } -.desktop-sidebar-custom { - width: 5vw; - background-size: calc(var(--squares-and-triangles-background-size) * 1vw); +.bg-background-light { + background-color: var(--background-light-color); + background-image: url(data:image/png;base64,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); } -.bg-sidebar { - 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) * 1vw) calc(var(--squares-and-triangles-background-height-coef) * 1vw); +.border-main-hard { + border-color: var(--main-hard-color); } -.mobile-topbar-custom { - width: 90vw; - height: 8%; - background-size: calc(var(--squares-and-triangles-background-size) * 1.6vh); - margin-left: 5vw; +.border-main-medium { + border-color: var(--main-medium-color); } -body:has(.lg-container:not([class~="lg-inline"])) #sidebar { - display: none !important; +.border-main-soft { + border-color: var(--main-soft-color); } -.logo-custom { - width: 3.5vw; - height: 20vw; - font-size: 2.5vw; - -webkit-text-stroke: 0.1vw var(--sidebar-stroke-color); - writing-mode: sideways-lr; - text-orientation: mixed; - display: block; +.border-background-dark { + border-color: var(--background-dark-color); } -.nav-icon-custom { - font-size: 3vw !important; - -webkit-text-stroke: 0.1vw var(--sidebar-stroke-color); - width: 5vw; - height: 5vw; - align-content: center; - text-align: center; +.border-background-light { + border-color: var(--background-light-color); } -.nav-icon-custom:hover { - font-size: 4vw; +.border-inset { + border-style: inset; } -.border-left-custom { - width: 0.5vw; - border-right: 0.3vh dashed var(--main-dark-color); +.border-outset { + border-style: outset; } -.border-right-custom { - width: 0.5vw; - border-left: 0.5vh dotted var(--main-dark-color); +.bg-sidebar { + 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) * 1rem) calc(var(--squares-and-triangles-background-height-coef) * 1rem); } .inset-shadow { - box-shadow: inset 0vh 1vh 1vh -1vh rgba(75, 81, 58, 0.5), - inset 1vh 0vh 1vh -1vh rgba(75, 81, 58, 0.5), - inset -1vh 0vh 1vh -1vh rgba(75, 81, 58, 0.5), - inset 0vh -0.5vh rgba(75, 81, 58, 0.5); + box-shadow: inset 0dvh 1dvh 1dvh -1dvh rgba(75, 81, 58, 0.5), + inset 1dvh 0dvh 1dvh -1dvh rgba(75, 81, 58, 0.5), + inset -1dvh 0dvh 1dvh -1dvh rgba(75, 81, 58, 0.5), + inset 0dvh -0.5dvh rgba(75, 81, 58, 0.5); } .text-stroke-\(--sidebar-stroke-color\) { -webkit-text-stroke-color: var(--sidebar-stroke-color); } -.text-stroke-\[0\.1vw\] { - -webkit-text-stroke-width: 0.1vw; +.text-stroke-\[0\.1dvw\] { + -webkit-text-stroke-width: 0.1dvw; } .theme-wheel { position: fixed; - width: 15vmax; - height: 15vmax; + width: 12rem; + height: 12rem; + box-shadow: 0.25rem 0.25rem 0.25rem var(--main-soft-color); border-radius: 50%; - background: var(--background-light-color); - opacity: 0.8; - border: 1vmax outset var(--background-dark-color); - box-shadow: 0 0 5vmax var(--main-light-color); opacity: 0; visibility: hidden; transform: scale(0.5); @@ -261,161 +358,238 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { .theme-icon { position: absolute; - width: 4vmax; - height: 4vmax; - border-radius: 50%; + width: 100%; + height: 100%; + top: 0; + left: 0; cursor: pointer; - display: flex; - align-items: center; - justify-content: center; transition: all 0.3s ease; - border: 0.1vmax inset var(--background-light-color); + color: white; background: var(--interlocked-hexagons-background); - background-size: contain; + background-size: 100% 100%; + font-size: 1.5rem; } .theme-icon:hover { - transform: scale(1.2); + filter: brightness(1.2); +} + +.theme-icon i { + position: absolute; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); + z-index: 1; } .theme-icon.active { - transform: scale(1.2); - border: 0.3vmax outset var(--main-light-color); + filter: brightness(1.3); } -.lg-backdrop { - background-color: var(--background-light-color); - opacity: 0.8 !important; +.theme-icon.active::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.25); + pointer-events: none; + clip-path: inherit; } -.lg-container:not([class~="lg-inline"]) .lg-outer { - width: 90%; - height: 90%; - margin: 5vh 5vw; +.theme-pin { + position: relative; + background: color-mix(in srgb, var(--main-soft-color) 75%, transparent); + box-shadow: 0rem 0rem 1rem var(--main-hard-color); + border: 0.5rem solid color-mix(in srgb, var(--main-soft-color) 90%, transparent); + backdrop-filter: blur(2px); + top: 35%; + left: 35%; + width: 30%; + height: 30%; + z-index: 1002; + border-radius: 50%; } -.lg-thumb-outer { - background-color: var(--background-light-color) !important; - opacity: 0.8 !important; +.custom-svg-marker { + background: transparent; + border: none; } -.lg-prev { - background-color: var(--background-light-color); - color: var(--main-dark-color); +.custom-svg-marker svg { + filter: drop-shadow(2px 2px 2px rgba(0,0,0,0.3)); + transition: transform 0.2s; } -.lg-next { - background-color: var(--background-light-color); - color: var(--main-dark-color); +.custom-svg-marker:hover svg { + transform: scale(1.1); +} + +.grid-tooltip { + position: relative; + display: inline-block; + box-shadow: inset 0rem 0rem 0.5rem var(--main-soft-color); + transition: box-shadow 0.5s ease-in-out; +} + +.grid-tooltip:hover { + box-shadow: none; } -.lg-counter { - color: var(--main-dark-color); - font-family: 'Spectral'; - font-size: 1vmax; - padding-top: 0; - height: 1vmax; +.grid-tooltip-text { + visibility: hidden; + opacity: 0; + transition: opacity 0.5s; + width: 100%; + top: 100%; + background-color: var(--background-dark-color); + color: var(--main-hard-color); + text-align: center; + padding: 0.25rem 0.25rem; + margin-top: 0.25rem; + border-radius: 0.5rem; + position: absolute; + z-index: 1001; } -.lg-container:not([class~="lg-inline"]) .lg-counter { - font-size: 2vmax; - height: 2vmax; +.grid-tooltip:hover .grid-tooltip-text { + visibility: visible; + opacity: 1; } -.lg-content { - top: 2.5vmax !important; - bottom: calc(10vmin + 5vmax) !important; +.grid-tooltip-text::after { + content: " "; + position: absolute; + bottom: 100%; + left: 50%; + margin-left: -0.25rem; + border-width: 0.25rem; + border-style: solid; + border-color: transparent transparent var(--background-dark-color) transparent; } -.lg-thumb-item { - width: 10vmin !important; - height: 10vmin !important; +.grid-item { + position: relative; + display: inline-block; + width: 10rem; } -.lg-container:not([class~="lg-inline"]) .lg-content { - top: 4vmax !important; +.grid-sizer { + position: relative; + display: inline-block; + width: 10.1rem; } -.lg-toolbar .lg-icon, -.lg-content .lg-icon { - padding: 0 0.7vmax; - font-size: 1.5vmax !important; - height: 1.5vmax; - width: auto; - color: var(--main-dark-color); +.grid-item-index { + visibility: visible; + opacity: 1; + transition: opacity 0.5s; + top: 0%; + left: 0%; + background-color: var(--main-soft-color); + color: var(--background-dark-color); + text-align: center; + padding: 0.25rem 0.25rem; + position: absolute; + z-index: 14; } -.lg-container:not([class~="lg-inline"]) .lg-toolbar .lg-icon, -.lg-container:not([class~="lg-inline"]) .lg-content .lg-icon { - font-size: 3vmax !important; - height: 3vmax; +.grid-item:hover .grid-item-index { + visibility: hidden; + opacity: 0; } -.lg-prev { - left: 0 !important; +.grid-item-2x { + width: 20rem; } -.lg-next { - right: 0 !important; +@container main (width >= 40rem) { + .grid-item { + width: 12rem; + } + + .grid-sizer { + width: 12.1rem; + } + + .grid-item-2x { + width: 24rem; + } } -.lg-toolbar .lg-icon:hover { - color: var(--main-medium-color); +@container main (width >= 76rem) { + .grid-item { + width: 18rem; + } + + .grid-sizer { + width: 18.1rem; + } + + .grid-item-2x { + width: 36rem; + } } -.lg-toolbar .lg-icon { - font-size: 1.5vmax !important; - color: var(--main-dark-color); +@container main (width >= 128rem) { + .grid-item { + width: 24rem; + } + + .grid-sizer { + width: 24.1rem; + } + + .grid-item-2x { + width: 48rem; + } } -.lg-content .lg-icon:hover { - color: var(--main-medium-color); +.goverlay { + background: color-mix(in srgb, var(--background-light-color) 50%, transparent) !important; + backdrop-filter: blur(1rem); } -.lg-sub-html { - padding: 0.7vmin 1.25vmin !important; +.glightbox-clean .gslide-description { + background: color-mix(in srgb, var(--background-dark-color) 50%, transparent) !important; + backdrop-filter: blur(1rem); } -.inline-gallery-container { - width: 80%; - height: 70vmin; +.glightbox-clean .gslide-title { + font-family: var(--font-andika) !important; + font-weight: 600 !important; + color: var(--main-soft-color) !important; + padding-bottom: 1rem !important; + margin-bottom: 0 !important; } -.light-gallery-captions { - p { - transform: translate3d(0, 0, 0px); - transition-delay: 500ms; - opacity: 1; - } +.glightbox-clean .gslide-desc { + font-size: 1rem !important; + font-family: var(--font-andika) !important; + font-weight: 400; + color: var(--main-medium-color) !important; } -.lg-slide-progress { - .light-gallery-captions { - h4 { - transform: translate3d(-60px, 0, 0px); - } +.glightbox-mobile .glightbox-container .gslide-description { + padding: 0 !important; +} - p { - transform: translate3d(60px, 0, 0px); - } +.glightbox-clean .gdesc-inner { + padding: 1rem 1rem !important; +} - h4, - p { - opacity: 0; - } - } +.glightbox-container .ginner-container { + height: 100dvh !important; } -.lg-current { - &.lg-slide-progress { - .light-gallery-captions { +.gslide-description { + flex-grow: 0; + flex-shrink: 1; + flex-basis: 0; +} - h4, - p { - transition-delay: 0ms; - } - } - } +.map-container .leaflet-container { + width: 100% !important; + height: 100% !important; } body[data-theme~="night"] .leaflet-tile, @@ -424,28 +598,29 @@ body[data-theme~="ram"] .leaflet-tile { } @media (min-aspect-ratio: 0.8/1) { - .ar-gt-0\.8\:mr-\[1\.2vmin\] { - margin-right: 1.2vmin; + .ar-gt-0\.8\:mr-6 { + margin-right: 1.5rem; } .ar-gt-0\.8\:w-fit { width: fit-content; } - .ar-gt-0\.8\:min-w-fit { - min-width: fit-content; + .ar-gt-0\.8\:w-\[100dvh\] { + width: 100dvh; + flex-basis: 100dvh; } - .ar-gt-0\.8\:min-w-\[10vh\] { - min-width: 10vh; + .ar-gt-0\.8\:min-w-fit { + min-width: fit-content; } - .ar-gt-0\.8\:max-w-\[20vh\] { - max-width: 20vh; + .ar-gt-0\.8\:min-w-\[10dvh\] { + min-width: 10dvh; } - .ar-gt-0\.8\:max-w-\[100vh\] { - max-width: 100vh; + .ar-gt-0\.8\:max-w-\[20dvh\] { + max-width: 20dvh; } .ar-gt-0\.8\:items-center { @@ -454,33 +629,12 @@ body[data-theme~="ram"] .leaflet-tile { } @media (max-aspect-ratio: 0.8/1) { - .bg-sidebar { - background-size: calc(var(--squares-and-triangles-background-width-coef) * 1.6vh) calc(var(--squares-and-triangles-background-height-coef) * 1.6vh); - } - - .inline-gallery-container { - width: 100%; - } - - .logo-custom { - display: none; - } - - .nav-icon-custom { - font-size: 5vh !important; - -webkit-text-stroke: 0.1vh var(--sidebar-stroke-color); - width: 8vh; - height: 8vh; - align-content: center; - text-align: center; - } - - .nav-icon-custom:hover { - font-size: 7vh; + .ar-lt-0\.8\:bg-background-light { + background-color: var(--background-light-color); } .bg-pattern-body { - background-size: 10vh; + background-size: 10dvh; } .ar-lt-0\.8\:grid { @@ -495,52 +649,52 @@ body[data-theme~="ram"] .leaflet-tile { flex-direction: row; } - .ar-lt-0\.8\:h-\[8vh\] { - height: 8vh; + .ar-lt-0\.8\:h-\[8dvh\] { + height: 8dvh; } - .ar-lt-0\.8\:h-\[10vh\] { - height: 10vh; + .ar-lt-0\.8\:h-\[10dvh\] { + height: 10dvh; } - .ar-lt-0\.8\:h-\[30vh\] { - height: 30vh; + .ar-lt-0\.8\:h-\[30dvh\] { + height: 30dvh; } - .ar-lt-0\.8\:h-\[90vh\] { - height: 90vh; + .ar-lt-0\.8\:h-\[90dvh\] { + height: 90dvh; } - .ar-lt-0\.8\:w-\[8vh\] { - width: 8vh; + .ar-lt-0\.8\:w-\[8dvh\] { + width: 8dvh; } .ar-lt-0\.8\:w-\[80\%\] { width: 80%; } - .ar-lt-0\.8\:w-\[90vw\] { - width: 90vw; + .ar-lt-0\.8\:w-\[90dvw\] { + width: 90dvw; } - .ar-lt-0\.8\:w-screen { - width: 100vw; + .ar-lt-0\.8\:w-\[100dvw\] { + width: 100dvw; } - .ar-lt-0\.8\:max-h-\[80\%\] { - max-height: 80%; + .ar-lt-0\.8\:max-h-\[30\%\] { + max-height: 30%; } - .ar-lt-0\.8\:max-h-\[calc\(100\%-25vh\)\] { - max-height: calc(100% - 25vh); + .ar-lt-0\.8\:max-h-\[80\%\] { + max-height: 80%; } - .ar-lt-0\.8\:text-\[5vh\] { - font-size: 5vh; + .ar-lt-0\.8\:max-h-\[92dvh\] { + max-height: 92dvh; } - .ar-lt-0\.8\:ml-\[5vw\] { - margin-left: 5vw; + .ar-lt-0\.8\:text-\[5dvh\] { + font-size: 5dvh; } .ar-lt-0\.8\:hidden { @@ -548,17 +702,31 @@ body[data-theme~="ram"] .leaflet-tile { } } -@media (max-aspect-ratio: 0.4/1) { - .ar-lt-0\.4\:w-\[100vw\] { - width: 100vw; +@media (min-aspect-ratio: 0.6/1) { + .ar-gt-0\.6\:flex-3\/5 { + flex: 60%; } - .ar-lt-0\.4\:ml-0 { - margin-left: 0; + .ar-gt-0\.6\:flex-2\/5 { + flex: 40%; + } +} + +@media (max-aspect-ratio: 0.6/1) { + .ar-lt-0\.6\:flex-col { + flex-direction: column; } } -.star { +.home-page-heart { + position: absolute; + color: var(--color-pink-700); + user-select: none; + pointer-events: none; + font-family: serif; +} + +.night-star { color: var(--color-yellow-100); animation-name: blink; animation-duration: 5s; @@ -567,6 +735,13 @@ body[data-theme~="ram"] .leaflet-tile { -webkit-user-select: none; } +.ram-frame { + width: 20vmin; + height: 20vmin; + user-select: none; + -webkit-user-select: none; +} + @keyframes blink { 0% { opacity: 1; diff --git a/views/index.html b/views/index.html deleted file mode 100644 index 2b8c937..0000000 --- a/views/index.html +++ /dev/null @@ -1,145 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>SAYA TODAY // Заглавная</title> - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet"> - <link href="/output.css" rel="stylesheet"> - <style> - .menu-outer-body { - border-radius: 10%; - width: 40vh; - height: 30vh; - } - - .menu-inner-body { - width: 85%; - } - - .flex-adaptive { - flex-direction: row; - } - - .language-icon { - max-width: calc(80% / {{ .AvailableLanguages | len }}); - max-height: 80%; - } - - @media (max-aspect-ratio: 0.8/1) { - .menu-outer-body { - border-radius: 5%; - } - - .menu-outer-body { - width: min(40vh, 100vw); - height: 40vh; - border-radius: 0; - } - - .language-icon { - max-width: 40%; - max-height: calc(80% / {{ .AvailableLanguages | len }}); - } - - .menu-inner-body { - width: 92.5%; - } - - .flex-adaptive { - flex-direction: column; - } - } - </style> -</head> - -<body data-theme="night" class="font-spectral text-main-dark text-[1.5vmax] overflow-hidden bg-interlocked-hexagons h-screen flex flex-col"> - - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - <div class="absolute star z-0">*</div> - - <script> - function switchTheme(theme) { - if (theme == "" || typeof theme == "undefined") { - const currentTheme = document.body.getAttribute('data-theme'); - switch (currentTheme) { - case "olive": theme = "lettuce"; break; - case "lettuce": theme = "night"; break; - case "night": theme = "olive"; break; - } - } - // localStorage.setItem('theme', theme); - document.body.setAttribute('data-theme', theme); - } - - const savedTheme = 'night'; - switchTheme(savedTheme); - </script> - - <div class="menu-outer-body p-4 z-1 bg-background-dark flex flex-col mx-auto my-auto opacity-70"> - <p class="font-patua mx-auto text-[2.5vmax] mt-[0.8vmin] mb-[0.8vmin]">saya.today</p> - <div class="menu-inner-body grow bg-background-light flex flex-adaptive mx-auto opacity-100"> - {{- range .AvailableLanguages }} - <img onclick="location.href='/{{ .Name }}/blog';" class="object-scale-down cursor-pointer flex-1 rounded-[20%] language-icon mx-auto my-auto relative" src="{{ .Flag }}" alt="{{ .Alt }}"> - {{- end }} - </div> - <p class="font-spectral mx-auto text-[1.5vmax] text-secondary mt-[0.8vmin] mb-[0.8vmin]">Choose your language</p> - </div> - - <script> - function generateStars() { - var clWidth = document.documentElement.clientWidth; - var clHeight = document.documentElement.clientHeight; - - stars = document.getElementsByClassName("star"); - for (let starIndex = 0; starIndex < stars.length; starIndex++) { - const star = stars[starIndex]; - const x = Math.floor(Math.random() * clWidth); - const y = Math.floor(Math.random() * clHeight * 0.8); - const rot = Math.random() * 90; - const animDuration = (Math.random() * 10) + 3; - const sz = Math.floor(Math.random() * 8) + 12; - star.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`; - star.style.animationDuration = `${animDuration}s`; - star.style.fontSize = `${sz}px`; - } - } - - document.addEventListener('DOMContentLoaded', generateStars); - - let generateStarsTimer; - window.addEventListener("resize", () => { - clearTimeout(generateStarsTimer); - generateStarsTimer = setTimeout(() => { - generateStars(); - }, 1000); - }); - </script> -</body> -</html> diff --git a/views/layouts/general-mail.html b/views/layouts/general-mail.html new file mode 100644 index 0000000..8f42c7a --- /dev/null +++ b/views/layouts/general-mail.html @@ -0,0 +1,105 @@ +<!DOCTYPE html> +<html lang="en"> + <meta charset="UTF-8"> + <head> + <style> +@font-face { + font-family: 'Philosopher'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/philosopher/v21/vEFV2_5QCwIS4_Dhez5jcWBuT0020Nqf.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Philosopher'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/philosopher/v21/vEFV2_5QCwIS4_Dhez5jcWBqT0020NqfZ7c.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +html { + font-family: 'Philosopher', sans-serif; + --noise-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==); +} + +body { + background-color: #e3e6ff; +} + +a { + text-decoration: underline; + text-shadow: none !important; +} + +.header { + width: 100%; + padding: 8px 8px 8px 32px; + font-size: 48px; + font-family: 'Philosopher'; + color: white; + text-shadow: -2px 0 #343b58, 0 2px #343b58, 2px 0 #343b58, 0 -2px #343b58; + background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); + background-color: #e3e6ff; + background-size: contain; + background-repeat: repeat; +} + +.content { + margin: 0 32px 0 32px; + padding: 16px 16px 32px 16px; + font-size: 24px; + text-shadow: -1px 0 #343b58, 0 1px #343b58, 1px 0 #343b58, 0 -1px #343b58; + color: white; + background-color: #c3caff; + background-image: var(--noise-image); +} + +.footer { + margin: 0; + padding: 1px 8px 1px 8px; + font-size: 16px; + color: white; +} + +.darkened { + background-color: #9aa5f4; + background-image: var(--noise-image); +} + +.outlier { + padding-inline: 4px; + background-color: #6c6e75; + background-image: var(--noise-image); +} + +table, th, td { + border: 1px solid black; +} + +td { + padding: 5px; +} + +img { + width: 200px; +} + </style> + </head> + + <body> + <div id="header" class="header"> + saya.today + </div> + + <div id="content" class="content" style="background-color: #e3e6ff !important;"> + {{ block "body" . }}{{ end }} + </div> + + <div id="footer" class="footer darkened"> + {{ block "footer" . }}{{ end }} + </div> + </body> +</html> diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index cd19bd0..62360ad 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -3,18 +3,143 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>SAYA TODAY // {{ .Title }}</title> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet"> - {{ block "header" . }}{{ end }} + <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"> <link href="/output.css" rel="stylesheet"> </head> -<body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-0.8:flex-col"> +<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh flex flex-row"> <script> + let map = null; + </script> + + <script> + function changeUrl(path, toAppend = false) { + const urlParams = new URLSearchParams(window.location.search); + const newPathname = location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname; + const newPath = toAppend ? `${newPathname}${path}` : `${path}`; + + history.pushState({}, '', `${newPath}${urlParams.entries.length == 0 ? '' : '?' + urlParams.toString()}`); + window.dispatchEvent(new Event('popstate')); + return false; + }; + + function convertRemToPixels(rem) { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); + } + </script> + + <script src="https://f003.backblazeb2.com/file/sayana-static/libs/glightbox/3.3.0/js/glightbox.min.js"></script> + + <script> + function sfc32(a, b, c, d) { + return function() { + a |= 0; b |= 0; c |= 0; d |= 0; + let t = (a + b | 0) + d | 0; + d = d + 1 | 0; + a = b ^ b >>> 9; + b = c + (c << 3) | 0; + c = (c << 21 | c >>> 11); + c = c + t | 0; + return (t >>> 0) / 4294967296; + } + } + + function cyrb128(str) { + let h1 = 1779033703, h2 = 3144134277, + h3 = 1013904242, h4 = 2773480762; + for (let i = 0, k; i < str.length; i++) { + k = str.charCodeAt(i); + h1 = h2 ^ Math.imul(h1 ^ k, 597399067); + h2 = h3 ^ Math.imul(h2 ^ k, 2869860233); + h3 = h4 ^ Math.imul(h3 ^ k, 951274213); + h4 = h1 ^ Math.imul(h4 ^ k, 2716044179); + } + h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067); + h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233); + h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213); + h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179); + h1 ^= (h2 ^ h3 ^ h4), h2 ^= h1, h3 ^= h1, h4 ^= h1; + return [h1>>>0, h2>>>0, h3>>>0, h4>>>0]; + } + + var seed = cyrb128("{{ .Title }}"); + var rand = sfc32(seed[0], seed[1], seed[2], seed[3]); + + function calculateVmin(percent) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const smallerDimension = Math.min(viewportWidth, viewportHeight); + + const vminValue = (smallerDimension / 100) * percent; + + return vminValue; + } + + function switchThemeDecor(theme) { + document.querySelectorAll('.theme-decor').forEach((e) => { + e.remove(); + }); + + if (theme == "" || typeof theme == "undefined") { + theme = document.body.getAttribute('data-theme'); + } + + vmin = calculateVmin(1); + var clWidth = document.documentElement.clientWidth; + var clHeight = document.documentElement.clientHeight; + + switch (theme) { + case "olive": break; + case "lettuce": break; + case "night": + for (i = 0; i < 25; i++) { + let star = document.createElement("div"); + star.append("*"); + star.classList.add("night-star", "theme-decor", "z-0", "absolute"); + const x = Math.floor(rand() * clWidth); + const y = Math.floor(rand() * clHeight * 0.8); + const rot = rand() * 90; + const animDuration = (rand() * 10) + 3; + const sz = Math.floor(rand() * 8) + 12; + star.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`; + star.style.animationDuration = `${animDuration}s`; + star.style.fontSize = `${sz}px`; + document.body.append(star); + } + break; + case "ram": + const frameCenters = new Array(4); + for (i = 1; i <= 4; i++) { + let frame = document.createElement("img"); + frame.src = `https://f003.backblazeb2.com/file/sayana-static/themes/ram/ram-frame${i}.webp`; + frame.classList.add("ram-frame", "theme-decor", "z-0", "absolute"); + let x, y; + outerLoop: + while (true) { + x = Math.floor(rand() * clWidth - 20*vmin); + y = Math.floor(rand() * clHeight - 20*vmin); + for (j = 0; j < i-1; j++) { + const xd = frameCenters[j].x - x; + const yd = frameCenters[j].y - y; + if (Math.sqrt(xd*xd + yd*yd) < 30*vmin) { + continue outerLoop; + } + } + break; + } + frameCenters[i-1] = {x: x, y: y}; + const rot = rand() * 90 - 45; + frame.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`; + document.body.append(frame); + } + break; + } + } + function switchTheme(theme) { if (theme == "" || typeof theme == "undefined") { const currentTheme = document.body.getAttribute('data-theme'); @@ -22,75 +147,138 @@ case "olive": theme = "lettuce"; break; case "lettuce": theme = "night"; break; case "night": theme = "ram"; break; - case "ram": theme = "olive"; break; + case "ram": theme = "lilac"; break; + case "lilac": theme = "olive"; break; } } localStorage.setItem('theme', theme); document.body.setAttribute('data-theme', theme); + switchThemeDecor(theme); } - const savedTheme = localStorage.getItem('theme') || 'ram'; + const savedTheme = localStorage.getItem('theme') || 'lilac'; switchTheme(savedTheme); </script> + <div id="general-page-top-embeds" class="flex" + hx-get="/api/v1/general-page/top-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> + </div> + <div id="general-page-top-embeds-trigger" hx-get="/api/v1/general-page/top-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-top-embeds" hx-swap="innerHTML"></div> + <script> const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; </script> - <div id="sidebar" class="bg-sidebar flex sticky flex-col ar-gt-0.8:items-center shadow-2xl z-20 w-[5vw] ar-lt-0.8:w-[90vw] ar-lt-0.4:w-[100vw] h-screen ar-lt-0.8:h-[8vh] ar-lt-0.8:ml-[5vw] ar-lt-0.4:ml-0"> - <div class="logo-custom ar-lt-0.8:hidden text-sidebar font-patua font-extrabold text-center relative z-20"> - saya.today - </div> - <div class="text-[3vw] ar-lt-0.8:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-0.8:flex-row gap-0 relative z-20"> - <i onclick="location.href='/';" class="fas fa-home w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="location.href='/{{ .Lang }}/blog';" class="fas fa-search w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="location.href='/{{ .Lang }}/map';" class="fas fa-map w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - </div> - <div class="theme-wheel" id="theme-wheel"> - <div class="theme-icon" data-theme="olive"></div> - <div class="theme-icon" data-theme="lettuce"></div> - <div class="theme-icon" data-theme="night"></div> - <div class="theme-icon" data-theme="ram"></div> + <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-20 h-20 [@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-20 h-20 hidden"> + <i onclick="return changeUrl('/../', true);" class="fas fa-circle-left flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> + <a href="/{{ .Lang }}" id="sidebar-home-button" class="w-20 h-20 hidden"> + <i onclick="return changeUrl('/{{ .Lang }}');" class="fas fa-home flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> + <a href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="w-20 h-20 hidden"> + <i onclick="return changeUrl('/{{ .Lang }}/blog');" class="fas fa-search flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> + <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="w-20 h-20 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> + <i onclick="toggleThemeWheel();" id="sidebar-theme-button" class="fas fa-swatchbook flex w-20 h-20 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-20 h-20 hidden"> + <i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> + </div> + <div class="theme-wheel" id="theme-wheel"> + <div class="theme-icon" data-theme="olive"></div> + <div class="theme-icon" data-theme="lettuce"></div> + <div class="theme-icon" data-theme="night"></div> + <div class="theme-icon" data-theme="ram"></div> + <div class="theme-icon" data-theme="lilac"></div> + <div class="theme-pin"></div> + </div> + <script> + sidebarHomeButton = document.getElementById("sidebar-home-button"); + sidebarMapButton = document.getElementById("sidebar-map-button"); + sidebarBlogButton = document.getElementById("sidebar-blog-button"); + sidebarBackButton = document.getElementById("sidebar-back-button"); + sidebarUserButton = document.getElementById("sidebar-user-button"); + + function updateSidebar() { + if (location.pathname == "" || location.pathname == "/") { + sidebarHomeButton.classList.add('hidden'); + sidebarMapButton.classList.add('hidden'); + sidebarBlogButton.classList.add('hidden'); + sidebarBackButton.classList.add('hidden'); + sidebarUserButton.classList.add('hidden'); + } else { + sidebarHomeButton.classList.remove('hidden'); + sidebarMapButton.classList.remove('hidden'); + sidebarBlogButton.classList.remove('hidden'); + sidebarBackButton.classList.remove('hidden'); + sidebarUserButton.classList.remove('hidden'); + lang = location.pathname.split("/")[1]; + sidebarHomeButton.setAttribute('href', `/${lang}`); + sidebarHomeButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}', false);`); + sidebarMapButton.setAttribute('href', `/${lang}/map`); + sidebarBlogButton.setAttribute('href', `/${lang}/blog`); + sidebarBlogButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}/blog', false);`); + sidebarUserButton.setAttribute('href', `/${lang}/user`); + sidebarUserButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}/user', false);`); + } + } + window.addEventListener("popstate", updateSidebar); + window.addEventListener("load", updateSidebar, {once: true}); + </script> </div> - </div> - <div class="bg-background-dark flex z-10 ar-lt-0.8:w-[90vw] ar-lt-0.4:w-[100vw] h-screen ar-lt-0.8:ml-[5vw] ar-lt-0.4:ml-0"> - <div class="pl-[0.5vw] bg-background-dark border-r-[0.4vmin] border-dashed border-main-dark"></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 border-r-2 border-dashed border-main-hard"></div> - <div class="ar-gt-0.8:min-w-fit ar-gt-0.8:max-w-[100vh] flex flex-col bg-background-dark relative pt-[1.6vmin] ml-[1.6vmin] pr-[1.6vmin]"> - <div {{ if .PublishedDate }} hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="#published-date" hx-swap="innerHTML" hx-trigger="load" {{ end }} - class="flex flex-row mb-[0.4vmin]"> - <i onclick="location.pathname += location.pathname.endsWith('/') ? '../' : '/../';" class="fas fa-circle-left hover:bg-main-dark hover:bg-opacity-10 cursor-pointer transition-colors duration-300 rounded-md text-[2vmax] mt-auto mb-auto px-[0.8vmin]"></i> - <p class="text-[2vmax] font-spectral italic font-extrabold select-none mt-auto pl-[0.8vmin] pr-[1.2vmin]">//</p> - <p class="text-[2vmax] font-spectral italic text-left mt-auto">{{ .Title }}</p> - <p id="published-date" class="grow text-[1.5vmax] font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p> - </div> - <hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]"> + <div class="max-h-100% flex flex-col grow bg-background-dark relative pt-4 ml-8 pr-4"> + <div id="general-page-header" class="max-xs:hidden [@media(max-height:32rem)]:hidden flex flex-col basis-8" + hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> + </div> + <div id="general-page-header-trigger" hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-header" hx-swap="innerHTML"></div> - {{ block "body" . }}{{ end }} + <hr class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-4 border-dotted border-main-hard mb-2"> - <div class="flex flex-row mt-[0.8vmin] mb-[0.4vmin]"> - <p class="grow text-[0.8vmax]/[0.9] font-spectral italic text-right text-secondary mr-2"> - All the photos on <a href="https://saya.today/">saya.today</a> are licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a> by <a href="https://t.me/EarlInisMona">Saya Andy</a> © {{ .PublishedYear }} - </p> - <img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> - <img src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> - <img src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> + <div id="general-page-body" class="flex flex-col grow overflow-y-auto" + hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> + </div> + <div id="general-page-body-trigger" hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-body" hx-swap="innerHTML"></div> + + <div id="general-page-footer" class="flex flex-row basis-4 my-1" + hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> + </div> + <div id="general-page-footer-trigger" hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-footer" hx-swap="innerHTML"></div> + + <hr class="border-t-4 border-dotted border-main-hard mb-2"> </div> - <hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]"> + <div class="pr-[0.5dvw] bg-background-dark shadow-2xl border-l-2 border-dotted border-main-hard"></div> </div> - - <div class="pr-[0.5vw] bg-background-dark shadow-2xl border-l-[0.4vmin] border-dotted border-main-dark"></div> </div> + <div class="grow bg-split-right"></div> + <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> + + <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"> + </div> + <div id="general-page-bottom-embeds-trigger" hx-get="/api/v1/general-page/bottom-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-bottom-embeds" hx-swap="innerHTML"></div> <script> const themeWheel = document.getElementById('theme-wheel'); - const themeButton = document.getElementById('theme-button'); + const themeButton = document.getElementById('sidebar-theme-button'); const themeIcons = document.querySelectorAll('.theme-icon'); + const themeWheelRadius = convertRemToPixels(6); themeIcons.forEach(icon => { if (icon.getAttribute('data-theme') == savedTheme) { @@ -98,17 +286,6 @@ } }); - function calculateVmin(percent) { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const smallerDimension = Math.min(viewportWidth, viewportHeight); - - const vminValue = (smallerDimension / 100) * percent; - - return vminValue; - } - function calculateVmax(percent) { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; @@ -121,49 +298,61 @@ } function positionThemeIcons() { - const icons = themeWheel.querySelectorAll('.theme-icon'); - const iconCount = icons.length; - const vmax = calculateVmax(1); - const wheelRadius = 7.5*vmax; // Radius of the wheel - const iconRadius = 4*vmax; // Distance from center to place icons - const iconSize = 3*vmax; // Half of icon size for centering + const sliceAngle = 360 / themeIcons.length; - icons.forEach((icon, index) => { - const angle = (index * 360 / iconCount) - 90; - const angleRad = (angle * Math.PI) / 180; + themeIcons.forEach((icon, index) => { + const startAngle = index * sliceAngle; + const endAngle = (index + 1) * sliceAngle; - const x = Math.cos(angleRad) * iconRadius + wheelRadius - iconSize; - const y = Math.sin(angleRad) * iconRadius + wheelRadius - iconSize; - - icon.style.left = x + 'px'; - icon.style.top = y + 'px'; + const clipPath = createSliceClipPath(startAngle, endAngle, themeWheelRadius); + icon.style.clipPath = clipPath; }); } + function createSliceClipPath(startAngle, endAngle, radius) { + const centerX = radius; + const centerY = radius; + + let points = `${centerX}px ${centerY}px`; + + const segments = Math.max(2, Math.ceil(Math.abs(endAngle - startAngle) / 5)); + const angleStep = (endAngle - startAngle) / segments; + + for (let i = 0; i <= segments; i++) { + const angle = ((startAngle + angleStep * i - 90) * Math.PI) / 180; + const x = centerX + Math.cos(angle) * radius; + const y = centerY + Math.sin(angle) * radius; + points += `, ${x}px ${y}px`; + } + + points += `, ${centerX}px ${centerY}px`; + + return `polygon(${points})`; + } + function positionThemeWheel() { const buttonRect = themeButton.getBoundingClientRect(); const buttonCenterX = buttonRect.left + buttonRect.width / 2; const buttonCenterY = buttonRect.top + buttonRect.height / 2; - const wheelRadius = calculateVmax(7.5); - const margin = calculateVmax(1); + const margin = 0; - let wheelX = buttonCenterX - wheelRadius; - let wheelY = buttonCenterY - wheelRadius; + let wheelX = buttonCenterX - themeWheelRadius; + let wheelY = buttonCenterY - themeWheelRadius; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; if (wheelX < margin) { wheelX = margin; - } else if (wheelX + (wheelRadius * 2) > viewportWidth - margin) { - wheelX = viewportWidth - (wheelRadius * 2) - margin; + } else if (wheelX + (themeWheelRadius * 2) > viewportWidth - margin) { + wheelX = viewportWidth - (themeWheelRadius * 2) - margin; } if (wheelY < margin) { wheelY = margin; - } else if (wheelY + (wheelRadius * 2) > viewportHeight - margin) { - wheelY = viewportHeight - (wheelRadius * 2) - margin; + } else if (wheelY + (themeWheelRadius * 2) > viewportHeight - margin) { + wheelY = viewportHeight - (themeWheelRadius * 2) - margin; } themeWheel.style.left = wheelX + 'px'; @@ -209,7 +398,13 @@ }); </script> - {{ block "footer" . }}{{ end }} + <script> + let decorResizeTimeout; + window.addEventListener('resize', function() { + clearTimeout(decorResizeTimeout); + decorResizeTimeout = setTimeout(switchThemeDecor, 300); + }); + </script> </body> </html> diff --git a/views/messages/new-post.html b/views/messages/new-post.html new file mode 100644 index 0000000..f1338f7 --- /dev/null +++ b/views/messages/new-post.html @@ -0,0 +1,19 @@ +{{ define "body" }} +<p>{{ .L.Mail.NewPost.Intro }}</p> +<table> + <tr> + <td rowspan="3"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}"><img src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Post.Metadata.Thumbnail }}.webp"></a></td> + <td class="darkened" style="font-size: 24px"><a style="color: #273de1 !important;" href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}">{{ .Post.Metadata.Title }}</a></td> + </tr> + <tr> + <td class="darkened" style="font-size: 16px">{{ .Post.Metadata.ShortDescription }}</td> + </tr> + <tr> + <td class="darkened" style="font-size: 16px">{{ .L.Mail.NewPost.CapturedOn }} {{ .Post.Metadata.ActionDate }}</td> + </tr> +</table> +{{ end }} + +{{ define "footer" }} +<p>{{ .UnsubscribeFooter }}</p> +{{ end }} diff --git a/views/messages/verify-email.html b/views/messages/verify-email.html new file mode 100644 index 0000000..dd6ef33 --- /dev/null +++ b/views/messages/verify-email.html @@ -0,0 +1,10 @@ +{{ define "body" }} +<p>{{ .L.Mail.VerifyEmail.Welcome }}</p> +<p>{{ .L.Mail.VerifyEmail.Intro }}</p> +<ol> + <li>{{ .L.Mail.VerifyEmail.GotoLink }} <a href="https://{{ .ClientHost }}/{{ .Lang }}/user?email_code={{ .VerificationCode }}" class="outlier">>>></a></li> + <li>{{ .L.Mail.VerifyEmail.InputCode }} <span class="outlier">{{ .VerificationCode }}</span><span></li> +</ol> +<p>{{ .L.Mail.VerifyEmail.IfRandom }}</p> +<a href="https://{{ .ClientHost }}" class="outlier">{{ .ClientHost }}</a> +{{ end }}
\ No newline at end of file diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 9fa8ea7..d209205 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -1,62 +1,63 @@ {{ define "body" }} -<div class="flex flex-row ar-lt-0.8:flex-col max-h-[calc(100%-7vmax)] ar-lt-0.8:max-h-[calc(100%-25vh)] flex-1 text-[1vmax]"> +<div class="flex flex-row ar-lt-0.8:flex-col text-base h-full"> <form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML" - class="tags-list ar-gt-0.8:min-w-[10vh] ar-gt-0.8:max-w-[20vh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] flex shrink-0 flex-col overflow-y-auto ar-gt-0.8:mr-[1.2vmin]"> - <fieldset> - <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.TagsHeader }}</legend> - <div class="flex flex-col ar-lt-0.8:grid grid-cols-5 grid-flow-row-dense"> - <div class="m-[0.4vmin]"> - <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> + class="tags-list ar-gt-0.8:min-w-[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%] 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-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-[0.4vmin]"> - <label><input type="checkbox" id="tag{{ .Name }}Checkbox" name="tags[]" value="{{ .Name }}" onclick="contextAll();" {{ if contains $.QueryTags .Name }}checked{{ end }}> {{ .Name }} <span class="text-secondary italic">{{ .Count }}</span></label> + </fieldset> + <fieldset class="mt-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-[0.8vmin]"> - <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> - <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col"> - <div class="m-[0.4vmin]"> - <label><input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-a-z"></i></label> - </div> - <div class="m-[0.4vmin]"> - <label><input type="radio" id="sortTitleDesc" name="sort" value="titleDesc" {{ if eq .QuerySort "titleDesc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-z-a"></i></label> - </div> - <div class="m-[0.4vmin]"> - <label><input type="radio" id="sortActionDateAsc" name="sort" value="actionDateAsc" {{ if eq .QuerySort "actionDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label> - </div> - <div class="m-[0.4vmin]"> - <label><input type="radio" id="sortActionDateDesc" name="sort" value="actionDateDesc" {{ if eq .QuerySort "actionDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label> - </div> - <div class="m-[0.4vmin]"> - <label><input type="radio" id="sortPublicationDateAsc" name="sort" value="publicationDateAsc" {{ if eq .QuerySort "publicationDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label> - </div> - <div class="m-[0.4vmin]"> - <label><input type="radio" id="sortPublicationDateDesc" name="sort" value="publicationDateDesc" {{ if eq .QuerySort "publicationDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label> - </div> - </div> - </fieldset> - <div class="flex flex-row bg-background-light my-[2vmin] p-[0.4vmin] rounded-[0.4vmin] justify-items-center"> - <i onclick="defaultSearchParams(); cleanHrefParams();" class="fas fa-rotate-left flex-1 text-center text-[1.5vmax] hover:bg-background-dark cursor-pointer transition-colors duration-300"></i> - <div class="w-[0.5vmin] bg-background-dark grow-0"></div> + </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-[1.5vmax] hover:bg-background-dark cursor-pointer transition-colors duration-300"></i> + <i onclick="setHrefParams();" class="fas fa-magnifying-glass text-center text-2xl hover:bg-background-light cursor-pointer transition-colors duration-300"></i> </button> </div> </form> - <div class="w-[0.5vh] border-r-[0.3vh] border-dotted border-main-dark bg-background-dark"></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 p-[2.4vmin]"> + class="blog-cards grow flex flex-col overflow-y-auto bg-background-light inset-shadow-[0_0_0.4rem_black] p-6"> Loading... </div> </div> -{{ end }} -{{ define "footer" }} <script> function selectAll() { const tagsContainers = document.querySelectorAll('form.tags-list'); @@ -97,6 +98,7 @@ function cleanHrefParams() { const url = new URL(window.location.href); url.search = ''; + window.history.pushState({}, '', url.toString()); } @@ -136,23 +138,27 @@ window.history.pushState({}, '', url.toString()); } - document.addEventListener('DOMContentLoaded', contextAll); -</script> + document.addEventListener('htmx:afterRequest', (e) => { + if (e.detail.xhr.status == 404 || e.detail.successful != true) { + return console.error(e); + } + if (e.detail.target.id == 'general-page-body') { + contextAll(); -<script> - const tagsContainers = document.querySelectorAll('form.tags-list'); - tagsContainers.forEach(tagsContainer => { - const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]'); - anyChecked = false; - checkboxes.forEach(checkbox => { - anyChecked = checkbox.checked ? true : anyChecked; - }); - if (!anyChecked) { - const selectAllCheckbox = tagsContainer.querySelector('#tagsAllCheckbox'); - selectAllCheckbox.checked = true; - selectAll(); + const tagsContainers = document.querySelectorAll('form.tags-list'); + tagsContainers.forEach(tagsContainer => { + const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]'); + anyChecked = false; + checkboxes.forEach(checkbox => { + anyChecked = checkbox.checked ? true : anyChecked; + }); + if (!anyChecked) { + const selectAllCheckbox = tagsContainer.querySelector('#tagsAllCheckbox'); + selectAllCheckbox.checked = true; + selectAll(); + } + }); } - }); + }, {once: true}); </script> - {{ end }} diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index 755744d..7170ad8 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -1,76 +1,61 @@ -{{ define "header" }} -<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lightgallery.min.css"> -<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lg-zoom.min.css"> -<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lg-thumbnail.min.css"> -{{- if .MapLocationX }} -<link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css"> -{{- end }} - -<style> - .map-container .leaflet-container { - width: 100% !important; - height: 100% !important; - } -</style> -{{ end }} - {{ define "body" }} -<div class="bg-background-light flex flex-col grow overflow-y-auto inset-shadow p-[2.4vmin] max-h-[calc(100%-7vmax)] ar-lt-0.8:max-h-[calc(100%-25vh)]"> +<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4 overflow-y-auto"> + <p class="max-xs:block [@media(max-height:32rem)]:block hidden font-andika text-2xl font-bold italic text-main-hard tracking-[.0125rem] mb-1">{{ .Title }}</p> + <p class="max-xs:block [@media(max-height:32rem)]:block hidden grow text-lg font-andika text-secondary">{{ .L.Metadata.Published }}: {{ .PublishedDate }}</p> + <p class="max-xs:block [@media(max-height:32rem)]:block hidden grow text-lg font-andika text-secondary">{{ .L.Metadata.Action }}: {{ .ActionDate }}</p> + <hr class="max-xs:block [@media(max-height:32rem)]:block hidden border-t-2 border-dotted border-main-hard mt-1 mb-2 w-full ml-auto mr-auto"> {{ .ParsedMarkdown }} {{- if .MapLocationX }} - <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%] ml-auto mr-auto"> - <div id="map-container" class="relative p-[0.8vmin] flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40vh] ar-lt-0.8:h-[30vh]"></div> - <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%] ml-auto mr-auto"> -{{- end }} -</div> -{{ end }} + <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto"> + <div id="map-container" class="relative p-1 flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40dvh] ar-lt-0.8:h-[30dvh]"></div> + <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto"> -{{ define "footer" }} -<script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/lightgallery.min.js"></script> -<script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/plugins/lg-zoom.min.js"></script> -<script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/plugins/lg-thumbnail.min.js"></script> -{{- if .MapLocationX }} -<script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script> + <script> + function initLocationMap(id, x, y, relativeErrorMeters = 0) { + map = L.map(id).setView([x, y], 8); -<script> - var map; - - 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>' + }).addTo(map); - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 18, - attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' - }).addTo(map); + if (relativeErrorMeters != 0) { + var circle = L.circle([x, y], { + color: '#8a9d8a', + fillColor: '#8a9d8a', + fillOpacity: 0.15, + radius: relativeErrorMeters + }).addTo(map); + } - if (relativeErrorMeters != 0) { - var circle = L.circle([x, y], { - color: '#8a9d8a', - fillColor: '#8a9d8a', - fillOpacity: 0.15, - radius: relativeErrorMeters - }).addTo(map); + var marker = L.marker([x, y], { + title: '{{ .Title }}', + }).addTo(map) + .bindPopup('<span><b>{{ .Title }}</b><br><a href="https://www.openstreetmap.org/#map=13/{{ .MapLocationX }}/{{ .MapLocationY }}">{{ .MapLocationX }}, {{ .MapLocationY }}</a></span>') + .openPopup(); } - var marker = L.marker([x, y], { - title: '{{ .Title }}', - }).addTo(map) - .bindPopup('<span><b>{{ .Title }}</b><br><a href="https://www.openstreetmap.org/#map=13/{{ .MapLocationX }}/{{ .MapLocationY }}">{{ .MapLocationX }}, {{ .MapLocationY }}</a></span>') - .openPopup(); - } + if (map) { + map.remove(); + map = null; + } - document.addEventListener('DOMContentLoaded', () => {initLocationMap( - 'map-container', - {{ .MapLocationX }}, - {{ .MapLocationY }}, - {{ .MapLocationAreaMeters }} - )}); + setTimeout(() => initLocationMap( + 'map-container', + {{ .MapLocationX }}, + {{ .MapLocationY }}, + {{ .MapLocationAreaMeters }} + ), 1000); - window.addEventListener('resize', function() { - setTimeout(() => { - map.invalidateSize(); - }, 100); - }); -</script> + var lightbox = GLightbox({ + selector: '.glightbox', + moreLength: 0 + }); + </script> {{- end }} -{{ end }}
\ No newline at end of file +</div> +{{ end }} + +{{ define "footer" }} +<div hx-get="/api/v1/like" hx-target="this" hx-swap="outerHTML" hx-trigger="load"></div> +{{ end }} diff --git a/views/pages/global-map.html b/views/pages/global-map.html index 05c39a1..306f442 100644 --- a/views/pages/global-map.html +++ b/views/pages/global-map.html @@ -3,11 +3,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>SAYA TODAY // {{ .L.GlobalMap.Header }}</title> + <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"> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet"> {{ block "header" . }}{{ end }} <link href="/output.css" rel="stylesheet"> <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css"> @@ -15,7 +12,7 @@ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css"> </head> -<body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-0.8:flex-col"> +<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh"> <script> function switchTheme(theme) { @@ -25,40 +22,56 @@ case "olive": theme = "lettuce"; break; case "lettuce": theme = "night"; break; case "night": theme = "ram"; break; - case "ram": theme = "olive"; break; + case "ram": theme = "lilac"; break; + case "lilac": theme = "olive"; break; } } localStorage.setItem('theme', theme); document.body.setAttribute('data-theme', theme); } - const savedTheme = localStorage.getItem('theme') || 'ram'; + const savedTheme = localStorage.getItem('theme') || 'lilac'; switchTheme(savedTheme); </script> <script> const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + function convertRemToPixels(rem) { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); + } </script> - <div id="sidebar" class="bg-sidebar flex sticky flex-col ar-gt-0.8:items-center shadow-2xl z-20 w-[5vw] ar-lt-0.8:w-screen h-screen ar-lt-0.8:h-[8vh]"> - <div class="logo-custom ar-lt-0.8:hidden text-sidebar font-patua font-extrabold text-center relative z-20"> - saya.today - </div> - <div class="text-[3vw] ar-lt-0.8:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-0.8:flex-row gap-0 relative z-20"> - <i onclick="location.href='/';" class="fas fa-home w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="location.href='/{{ .Lang }}/blog';" class="fas fa-search w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="location.href='/{{ .Lang }}/map';" class="fas fa-map w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> + <div id="sidebar" class="bg-sidebar block fixed shadow-[0_0_0.4rem_black] z-1001 w-[5rem] h-auto"> + <div class="text-[3rem] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1dvw] flex flex-col gap-0 relative z-1001"> + <a href="./" class="w-[5rem] h-[5rem]"> + <i class="fas fa-circle-left flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> + <a href="/{{ .Lang }}" class="w-[5rem] h-[5rem]"> + <i class="fas fa-home flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> + <a href="/{{ .Lang }}/blog" class="w-[5rem] h-[5rem]"> + <i class="fas fa-search flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> + <a href="/{{ .Lang }}/map" class="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> + <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook flex w-[5rem] h-[5rem] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 rounded-lg"></i> + <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="w-[5rem] h-[5rem]"> + <i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i> + </a> </div> <div class="theme-wheel" id="theme-wheel"> <div class="theme-icon" data-theme="olive"></div> <div class="theme-icon" data-theme="lettuce"></div> <div class="theme-icon" data-theme="night"></div> <div class="theme-icon" data-theme="ram"></div> + <div class="theme-icon" data-theme="lilac"></div> + <div class="theme-pin"></div> </div> </div> - <div id="map-container" class="bg-background-dark flex z-10 w-[95vw] ar-lt-0.8:w-screen h-screen ar-lt-0.8:h-[92vh]"></div> + <div id="map-container" class="bg-background-dark flex z-10 w-full h-full"></div> <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> @@ -68,6 +81,7 @@ const themeWheel = document.getElementById('theme-wheel'); const themeButton = document.getElementById('theme-button'); const themeIcons = document.querySelectorAll('.theme-icon'); + const themeWheelRadius = convertRemToPixels(6); themeIcons.forEach(icon => { if (icon.getAttribute('data-theme') == savedTheme) { @@ -87,49 +101,61 @@ } function positionThemeIcons() { - const icons = themeWheel.querySelectorAll('.theme-icon'); - const iconCount = icons.length; - const vmax = calculateVmax(1); - const wheelRadius = 7.5*vmax; // Radius of the wheel - const iconRadius = 4*vmax; // Distance from center to place icons - const iconSize = 3*vmax; // Half of icon size for centering + const sliceAngle = 360 / themeIcons.length; - icons.forEach((icon, index) => { - const angle = (index * 360 / iconCount) - 90; - const angleRad = (angle * Math.PI) / 180; + themeIcons.forEach((icon, index) => { + const startAngle = index * sliceAngle; + const endAngle = (index + 1) * sliceAngle; - const x = Math.cos(angleRad) * iconRadius + wheelRadius - iconSize; - const y = Math.sin(angleRad) * iconRadius + wheelRadius - iconSize; - - icon.style.left = x + 'px'; - icon.style.top = y + 'px'; + const clipPath = createSliceClipPath(startAngle, endAngle, themeWheelRadius); + icon.style.clipPath = clipPath; }); } + function createSliceClipPath(startAngle, endAngle, radius) { + const centerX = radius; + const centerY = radius; + + let points = `${centerX}px ${centerY}px`; + + const segments = Math.max(2, Math.ceil(Math.abs(endAngle - startAngle) / 5)); + const angleStep = (endAngle - startAngle) / segments; + + for (let i = 0; i <= segments; i++) { + const angle = ((startAngle + angleStep * i - 90) * Math.PI) / 180; + const x = centerX + Math.cos(angle) * radius; + const y = centerY + Math.sin(angle) * radius; + points += `, ${x}px ${y}px`; + } + + points += `, ${centerX}px ${centerY}px`; + + return `polygon(${points})`; + } + function positionThemeWheel() { const buttonRect = themeButton.getBoundingClientRect(); const buttonCenterX = buttonRect.left + buttonRect.width / 2; const buttonCenterY = buttonRect.top + buttonRect.height / 2; - const wheelRadius = calculateVmax(7.5); - const margin = calculateVmax(1); + const margin = 0; - let wheelX = buttonCenterX - wheelRadius; - let wheelY = buttonCenterY - wheelRadius; + let wheelX = buttonCenterX - themeWheelRadius; + let wheelY = buttonCenterY - themeWheelRadius; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; if (wheelX < margin) { wheelX = margin; - } else if (wheelX + (wheelRadius * 2) > viewportWidth - margin) { - wheelX = viewportWidth - (wheelRadius * 2) - margin; + } else if (wheelX + (themeWheelRadius * 2) > viewportWidth - margin) { + wheelX = viewportWidth - (themeWheelRadius * 2) - margin; } if (wheelY < margin) { wheelY = margin; - } else if (wheelY + (wheelRadius * 2) > viewportHeight - margin) { - wheelY = viewportHeight - (wheelRadius * 2) - margin; + } else if (wheelY + (themeWheelRadius * 2) > viewportHeight - margin) { + wheelY = viewportHeight - (themeWheelRadius * 2) - margin; } themeWheel.style.left = wheelX + 'px'; @@ -179,12 +205,37 @@ var map; var markers = L.markerClusterGroup(); + function createCustomIcon(color, borderColor = "var(--main-soft-color)") { + const svgIcon = ` + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 1C7.5 1 4 4.5 4 9c0 6.5 8 14 8 14s8-7.5 8-14c0-4.5-3.5-8-8-8zm0 11c-1.5 0-3-1.5-3-3s1.5-3 3-3 3 1.5 3 3-1.5 3-3 3z" + fill="${color}" + stroke="${borderColor}" + stroke-width="1" + stroke-linejoin="round"/> + </svg> + `; + + return L.divIcon({ + html: svgIcon, + className: 'custom-svg-marker', + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -32] + }); + } + 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>' + attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', + zoomControl: false + }).addTo(map); + + L.control.zoom({ + position: 'topright' }).addTo(map); map.addLayer(markers); @@ -218,10 +269,11 @@ } var marker = L.marker([x, y], { + icon: createCustomIcon(titleColor), title: title, }).bindPopup(` <div class="flex flex-row justify-center justify-items-center"> - <img onclick="location.href='${link}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[3vmax] h-[3vmax] mr-[0.5vmax]" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/${thumbnail}.webp"> + <img onclick="location.href='${link}';" class="cursor-pointer object-cover rounded-[10%] select-none w-12 h-12 mr-1" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/${thumbnail}.webp"> <span><b><a href="${link}">${title}</a></b><br><a href="https://www.openstreetmap.org/#map=13/${x}/${y}">${x}, ${y}</a></span> </div>`); @@ -242,7 +294,5 @@ }); </script> - {{ block "footer" . }}{{ end }} - </body> </html> diff --git a/views/pages/home-page.html b/views/pages/home-page.html new file mode 100644 index 0000000..11d0f7b --- /dev/null +++ b/views/pages/home-page.html @@ -0,0 +1,122 @@ +{{ 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="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 + 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> + <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> + </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> + <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> + <li class="ml-4 mb-1">{{ index .FunFacts 1 }}</li> + <li class="ml-4">{{ index .FunFacts 2 }}</li> + </ul> + </div> + </div> + + <div class="ar-gt-0.6:flex-2/5 justify-center content-start p-8"> + <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> + <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> + <span class="font-bold">Telegram:</span> + <span>@EarlInisMona</span> + </a> + <a href="https://discord.com/users/356057270951346176"> + <i class="fa-brands fa-discord w-4 h-4 mr-1"></i> + <span class="font-bold">Discord:</span> + <span>montferrat</span> + </a> + <a href="mailto:saya.andy@posteo.com"> + <i class="fas fa-envelope w-4 h-4 mr-1"></i> + <span class="font-bold">E-mail:</span> + <span>saya.andy@posteo.com</span> + </a> + </div> + + <div class="flex relative aspect-square justify-center content-center mb-4"> + {{- range $_ := iterate .FilledHeartCount }} + <div class="home-page-heart z-50">♥</div> + {{- end }} + {{- range $_ := iterate .OutlineHeartCount }} + <div class="home-page-heart z-50">♡</div> + {{- end }} + <img src="https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/{{ .GifName }}" + class="home-page-gif z-49 -rotate-12 max-w-[70%] max-h-[70%] border-4 border-(--background-dark-color) border-inset"> + </div> + + <script> + function shuffleHearts() { + vmin = calculateVmin(1); + document.querySelectorAll('.home-page-heart').forEach((e) => { + let top, left; + do { + top = Math.floor(rand() * 90) + 5; + left = Math.floor(rand() * 90) + 5; + } while (top > 30 && top < 70 && left > 30 && left < 70); + const opacity = rand() * 0.7 + 0.2; + const size = rand() * 3.2 + 0.8; + e.style["top"] = `calc(${top}% - ${size}vmin / 2)`; + e.style["left"] = `calc(${left}% - ${size}vmin / 2)`; + e.style["opacity"] = `${opacity}`; + e.style["font-size"] = `${size}vmin`; + }); + } + + let shuffleHeartsResizeTimeout; + function shuffleHeartsResizeCb() { + clearTimeout(shuffleHeartsResizeTimeout); + shuffleHeartsResizeTimeout = setTimeout(shuffleHearts, 300); + } + window.addEventListener('resize', shuffleHeartsResizeCb); + + document.addEventListener('popout', (e) => { + window.removeEventListener('resize', shuffleHeartsResizeCb); + shuffleHeartsResizeCb = null; + }); + </script> + + <script> + document.addEventListener('htmx:afterRequest', (e) => { + if (e.detail.xhr.status == 404 || e.detail.successful != true) { + return console.error(e); + } + if (e.detail.target.id == 'general-page-body') { + shuffleHearts(); + } + }, {once: true}); + </script> + + <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> + <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;"> + <i class="fas fa-home w-full h-full text-[10cqi] content-center text-center text-main-hard"></i> + <p class="p-1">{{ .L.HomePage.HomePageDescription }}</p> + <i class="fas fa-search w-full h-full text-[10cqi] content-center text-center text-main-hard"></i> + <p class="p-1">{{ .L.HomePage.BlogSearchDescription }}</p> + <i class="fas fa-map w-full h-full text-[10cqi] content-center text-center text-main-hard"></i> + <p class="p-1">{{ .L.HomePage.MarkerMapDescription }}</p> + <i class="fas fa-swatchbook w-full h-full text-[10cqi] content-center text-center text-main-hard"></i> + <p class="p-1">{{ .L.HomePage.ThemeSwitchDescription }}</p> + </div> + </div> + </div> +</div> +{{ end }} diff --git a/views/pages/language-pick.html b/views/pages/language-pick.html new file mode 100644 index 0000000..5a9d229 --- /dev/null +++ b/views/pages/language-pick.html @@ -0,0 +1,16 @@ +{{ define "body" }} +<div class="flex flex-col justify-center text-3xl h-[100%]"> + {{- range .AvailableLanguages }} + <hr class="border-t-2 border-dotted border-main-hard"> + <div class="nth-[2n+1_of_div]:bg-(--background-medium-color)/50 nth-[2n_of_div]:bg-(--background-light-color)/50 p-2.5 flex flex-row justify-center justify-items-center"> + <a href="/{{ .Name }}" class="w-12 h-12 basis-12 shrink-0 mr-4"> + <img onclick="return changeUrl('{{ .Name }}');" class="w-full h-full aspect-square cursor-pointer object-cover rounded-md select-none" src="{{ .Flag }}"> + </a> + <a href="/{{ .Name }}" onclick="return changeUrl('{{ .Name }}');" class="cursor-pointer"> + {{ .Alt }} + </a> + </div> + {{- end }} + <hr class="border-t-2 border-dotted border-main-hard"> +</div> +{{ end }} diff --git a/views/pages/unsubscribe-page.html b/views/pages/unsubscribe-page.html new file mode 100644 index 0000000..3876818 --- /dev/null +++ b/views/pages/unsubscribe-page.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ .L.UnsubscribePage.Header }} // SAYA TODAY</title> +<style> + html { + --noise-image: url(data:image/png;base64,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); + } +</style> +</head> + +<body style="width: 100dvw; height: 100dvh; display: flex; flex-direction: column; position: fixed"> + + <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.1)'></div> + + <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;"> + <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 2rem;">{{ .StatusEmoji }}</p> + </div> + + <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;"> + <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 1.5rem;">{{ .StatusText }}</p> + </div> + + <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.2)'></div> + +</body> + +</html> diff --git a/views/pages/user-page.html b/views/pages/user-page.html new file mode 100644 index 0000000..7d39825 --- /dev/null +++ b/views/pages/user-page.html @@ -0,0 +1,205 @@ +{{ define "body" }} +<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] 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"> + <div class="flex flex-col w-full sm:flex-3/5 grow-0"> + <label class="block text-main-medium font-bold mb-1 z-22"> + {{ .L.UserProfile.EmailHeader }} + </label> + <input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" + id="email-input" name="email" type="text" value="{{ .Email }}" placeholder="{{ .L.UserProfile.TypeEmail }}" maxlength="64" + data-validated-email="{{ .Email }}" onchange="checkIfValidated(this);"> + </div> + </div> + <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" + hx-post="/api/v1/email/send-verification-code" hx-target="#email-message" hx-swap="outerHTML" hx-indicator="#email-form"> + {{ .L.UserProfile.SendCodeButton }} + </button> + </form> + <div id="email-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> + + <hr class="my-2 border-t-4 border-dotted border-main-hard"> + + <form id="email-verification-form" class="w-full mb-2 flex flex-col relative hidden crossable" + {{ if .EmailCode }}hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"{{ end }}> + <div class="flex flex-row w-full mb-4 z-21"> + <div class="flex flex-col w-full sm:flex-2/5 grow-0"> + <label class="block text-main-medium font-bold ml-1 z-22"> + {{ .L.UserProfile.VerificationCodeHeader }} + </label> + <input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" + name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16" autocapitalize="characters"> + </div> + </div> + <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" + hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-indicator="#email-verification-form"> + {{ .L.UserProfile.VerifyButton }} + </button> + </form> + <div id="verification-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> +</div> + +<hr class="my-4 border-t-4 border-dotted border-main-hard"> + +<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4"> + <form id="subs-form" class="w-full mb-2 flex flex-col relative crossable"> + <div class="flex flex-col w-full mb-4 z-21"> + <label class="block text-main-medium font-bold mb-1 z-22"> + {{ .L.UserProfile.SubscriptionHeader }} + </label> + <div class="flex flex-col sm:flex-row w-full"> + <div class="m-1 w-1/3"> + <label><input class="mr-1" type="radio" id="tagsNone" name="tags" value="none" {{ if eq .TagsPicked "none" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsNone }}</label> + </div> + <div class="m-1 w-1/3"> + <label><input class="mr-1" type="radio" id="tagsAll" name="tags" value="all" {{ if eq .TagsPicked "all" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsAll }}</label> + </div> + <div class="m-1 w-1/3"> + <label><input class="mr-1" type="radio" id="tagsSpecific" name="tags" value="specific" {{ if eq .TagsPicked "specific" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsSpecific }}</label> + </div> + </div> + <div id="pick-tags-form" class="flex flex-col w-full relative crossable"> + <input type="text" class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" + list="existing-tags" onchange="addTag(this.value);" onkeydown="if (event.keyCode === 13) {addTag(this.value); this.value=''; return false;}"> + <datalist id="existing-tags"> + {{- range .ExistingTags }} + <option value="{{ .Name }}"> + {{- end }} + </datalist> + <div id="tags-picked-list" class="bg-background-dark flex flex-wrap inset-shadow-[0_0_0.4rem_black] p-2 m-4 w-full"> + {{- range .TagsPickedList }} + <div id="tagPicked{{ . }}" class="bg-main-hard text-background-light m-2 flex flex-row"> + <p class="py-1 px-2">{{ . }}</p> + <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked{{ . }}'));">✘</p> + <input type="hidden" name="tags_picked" value="{{ . }}"> + </div> + {{- end }} + </div> + </div> + <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" + hx-put="/api/v1/subs" hx-target="#subs-message" hx-swap="outerHTML" hx-indicator="#subs-form"> + {{ .L.UserProfile.SaveButton }} + </button> + </div> + </form> + <div id="subs-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> +</div> + +<script> +htmx.on("htmx:beforeSwap", (e) => { + if (e.detail.xhr.status === 422) { + e.detail.shouldSwap = true; + e.detail.isError = false; + } +}); + +var formAllowTimeout; +var verificationHideTimeout; + +var url = new URL(window.location.href); +url.searchParams.delete('email_code'); +window.history.pushState({}, '', url.toString()); + +htmx.on("htmx:afterSwap", (e) => { + emailMessage = htmx.find("#email-message"); + verificationMessage = htmx.find("#verification-message"); + emailForm = htmx.find("#email-form"); + emailInput = htmx.find("#email-input"); + emailVerificationForm = htmx.find("#email-verification-form"); + subsForm = htmx.find("#subs-form"); + tryAgainTimeDiff = parseInt(emailMessage.dataset.strikedEndTime || "0") - new Date().getTime(); + verificationWaitingTimeDiff = parseInt(emailMessage.dataset.codeExpiryTime || "0") - new Date().getTime(); + + if (tryAgainTimeDiff > 0) { + emailForm.classList.add("disabled"); + formAllowTimeout = setTimeout(() => { + emailForm.classList.remove("disabled"); + emailMessage.classList.add("hidden"); + }, tryAgainTimeDiff); + } else { + emailForm.classList.remove("disabled"); + } + + if (verificationWaitingTimeDiff > 0) { + verificationHideTimeout = setTimeout(() => emailVerificationForm.classList.add("hidden"), verificationWaitingTimeDiff); + emailVerificationForm.classList.remove("hidden"); + } else { + emailVerificationForm.classList.add("hidden"); + } + + if ((verificationMessage.dataset.hideVerificationPanel || "false") == "true") { + emailVerificationForm.classList.add("hidden"); + } + + if (emailInput.dataset.validatedEmail != "") { + subsForm.classList.remove("disabled"); + } else { + subsForm.classList.add("disabled"); + } +}); + +function checkIfValidated(elem) { + if ((elem.dataset.validatedEmail != "") && (elem.value == elem.dataset.validatedEmail)) { + elem.classList.add("!bg-green-300/20"); + } else { + elem.classList.remove("!bg-green-300/20"); + } +} + +checkIfValidated(htmx.find("#email-input")); + +function toggleTagsPick(elem) { + const pickTagsForm = htmx.find("#pick-tags-form"); + if (elem.value === "specific" && elem.checked) { + pickTagsForm.classList.remove("disabled"); + } else { + pickTagsForm.classList.add("disabled"); + } +} + +toggleTagsPick(htmx.find("#tagsSpecific")); + +function addTag(name) { + name = name.trim(); + const subsMessage = htmx.find("#subs-message"); + const existingTags = htmx.findAll(htmx.find("#existing-tags"), "option"); + found = [...existingTags].find(opt => opt.value===name); + if (!found) { + subsMessage.classList.remove("hidden"); + subsMessage.innerHTML = "{{ .L.UserProfile.TagDoesNotExist }}".replace("{}", name); + return; + } + + const tagsPickedList = htmx.find("#tags-picked-list"); + const subscribedTags = htmx.findAll(tagsPickedList, "div input"); + found = [...subscribedTags].find(opt => opt.value===name); + if (found) { + subsMessage.classList.remove("hidden"); + subsMessage.innerHTML = "{{ .L.UserProfile.TagAlreadyAdded }}".replace("{}", name); + return; + } + + tagsPickedList.innerHTML += ` + <div id="tagPicked${name}" class="bg-main-hard text-background-light m-2 flex flex-row"> + <p class="py-1 px-2">${name}</p> + <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked${name}'));">✘</p> + <input type="hidden" name="tags_picked" value="${name}"> + </div> + `; +} + +htmx.on("htmx:configRequest", (e) => { + const params = e.detail.parameters; + + if (Array.isArray(params.tags_picked)) { + tags_join = params.tags_picked[0] || ""; + for (i = 1; i < params.tags_picked.length; i++) { + tags_join += ","; + tags_join += params.tags_picked[i]; + } + params.tags_picked = tags_join; + } +}) +</script> +{{ end }}
\ No newline at end of file diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html new file mode 100644 index 0000000..8d555da --- /dev/null +++ b/views/partials/blog-page-like-button.html @@ -0,0 +1,6 @@ +<button + hx-put="/api/v1/like" hx-vals='{"like": {{ not .Liked }}}' hx-target="this" hx-swap="outerHTML" hx-trigger="click" + class="text-sm/[0.9] shrink-0 font-andika text-left px-1 py-0.5 {{ if .Liked }}bg-main-soft hover:bg-main-medium text-background-dark border-inset{{ else }}bg-main-hard hover:bg-main-medium text-background-light border-outset{{ end }} cursor-pointer border-2 border-background-dark"> + <i class="fas fa-thumbs-up w-3 h-3"></i> + <span class="italic ml-0.5 text-background-light">({{ .LikedCount }})</span> +</button>
\ No newline at end of file diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html index 62e931a..b50f419 100644 --- a/views/partials/catalogue-blog-cards.html +++ b/views/partials/catalogue-blog-cards.html @@ -1,18 +1,27 @@ {{- range .BlogPages }} -<hr class="first-of-type:hidden border-t-[0.3vh] my-[0.8vmin] border-dotted border-main-dark w-[80%] mx-auto"> -<div class="m-[0.4vmin] flex flex-row justify-center justify-items-center"> - <img onclick="location.href='{{ .ArticleLink }}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[10vmin] h-[10vmin] aspect-square my-[0.4vmin] mr-[0.8vmin]" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp"> - <div class="grow my-[0.4vmin] mx-[0.8vmin] flex flex-col justify-center"> - <div class="flex flex-row gap-[5%] max-w-[100%]"> - <a href="{{ .ArticleLink }}" class="flex-9/12 grow-0 block text-[1vmax] cursor-pointer"> +<hr class="first-of-type:hidden my-1 border-t-2 border-dotted border-main-hard"> +<div class="m-1 flex flex-row justify-center justify-items-center"> + <a href="{{ .ArticleLink }}" class="w-24 h-24 basis-24 shrink-0 my-2.5 mr-2"> + <img onclick="return changeUrl('{{ .ArticleLink }}');" class="w-full h-full aspect-square cursor-pointer object-cover rounded-lg select-none" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp"> + </a> + <div class="grow my-0.5 mx-1 flex flex-col justify-center"> + <div class="flex flex-row gap-2 max-w-full"> + <a href="{{ .ArticleLink }}" onclick="return changeUrl('{{ .ArticleLink }}');" class="grow text-base cursor-pointer"> <span class="font-extrabold">{{ .Title }}</span> <span class="font-extrabold select-none text-secondary">//</span> <span class="italic text-secondary">{{ .ActionDate }}</span> </a> - <p class="flex-3/12 grow-0 text-[1vmax] italic text-secondary text-right">{{ .PublishedTime }}</p> + <p class="basis-56 text-base italic text-secondary text-right">{{ .PublishedTime }}</p> </div> - <p class="text-[1vmax] text-main-dark">{{ .ShortDescription }}</p> - <p class="text-[1vmax] text-secondary">{{ $.L.TagsLabel }} {{ template "catalogue-blog-card-tags.html" . }}</p> + <p class="text-base text-main-hard">{{ .ShortDescription }}</p> + <p class="text-base text-secondary italic"> + <i class="fas fa-thumbs-up w-4 h-4"></i> + <span>{{ .LikeCount }}</span> + <span class="font-extrabold select-none">//</span> + <i class="fas fa-eye w-4 h-4"></i> + <span>{{ .ViewCount }}</span> + </p> + <p class="text-base text-secondary">{{ $.L.TagsLabel }} {{ template "catalogue-blog-card-tags.html" . }}</p> </div> </div> {{- end }}
\ No newline at end of file diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html new file mode 100644 index 0000000..d697c14 --- /dev/null +++ b/views/partials/general-page-body.html @@ -0,0 +1 @@ +{{ block "body" . }}{{ end }}
\ No newline at end of file diff --git a/views/partials/general-page-bottom-embeds.html b/views/partials/general-page-bottom-embeds.html new file mode 100644 index 0000000..4c36b26 --- /dev/null +++ b/views/partials/general-page-bottom-embeds.html @@ -0,0 +1 @@ +{{ block "bottom-embeds" . }}{{ end }}
\ No newline at end of file diff --git a/views/partials/general-page-footer.html b/views/partials/general-page-footer.html new file mode 100644 index 0000000..9b91bf0 --- /dev/null +++ b/views/partials/general-page-footer.html @@ -0,0 +1,7 @@ +{{ block "footer" . }}{{ end }} +<p class="grow content-center text-base/[1] font-andika italic text-right text-secondary mr-1"> + All the photos on <a href="https://saya.today/">saya.today</a> are licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a> by <a href="https://t.me/EarlInisMona">Saya Andy</a> © {{ .PublishedYear }} +</p> +<img class="max-w-4 max-h-4 self-center" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="CC"> +<img class="max-w-4 max-h-4 self-center" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="BY"> +<img class="max-w-4 max-h-4 self-center" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="SA">
\ No newline at end of file diff --git a/views/partials/general-page-header.html b/views/partials/general-page-header.html new file mode 100644 index 0000000..90180de --- /dev/null +++ b/views/partials/general-page-header.html @@ -0,0 +1,7 @@ +<title>{{ .Title }} // SAYA TODAY</title> +<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 }} diff --git a/views/partials/general-page-top-embeds.html b/views/partials/general-page-top-embeds.html new file mode 100644 index 0000000..1407b50 --- /dev/null +++ b/views/partials/general-page-top-embeds.html @@ -0,0 +1 @@ +{{ block "top-embeds" . }}{{ end }}
\ No newline at end of file diff --git a/views/partials/personal-page-status.html b/views/partials/personal-page-status.html new file mode 100644 index 0000000..a4e47c2 --- /dev/null +++ b/views/partials/personal-page-status.html @@ -0,0 +1,12 @@ +<div id="{{ .StatusId }}" class='z-26 w-full inset-shadow-[0_0_0.4rem_black] + {{if eq .Status "OK"}}bg-green-700 text-green-100 + {{else if eq .Status "Failed"}}bg-red-700 text-red-100 + {{else}}bg-background-dark text-main-soft{{end}} + {{if eq .Message ""}}hidden{{end}} + py-2 px-4 text-center' + {{- range $_, $val := .DataAttributes }} + {{ $val }} + {{- end }} + > + {{ .Message }} +</div>
\ No newline at end of file |