| -rw-r--r-- | .github/workflows/build-and-deploy-prod.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/build-and-deploy-stage.yml | 2 | ||||
| -rw-r--r-- | config/config.go | 86 | ||||
| -rw-r--r-- | config/config.local.yaml | 28 | ||||
| -rw-r--r-- | config/config.prod.yaml | 30 | ||||
| -rw-r--r-- | config/config.stage.yaml | 29 | ||||
| -rw-r--r-- | deploy/inventory.yml | 2 | ||||
| -rw-r--r-- | deploy/playbook.yml | 28 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 61 | ||||
| -rw-r--r-- | internal/blog/index.go | 32 | ||||
| -rw-r--r-- | internal/blog/s3.go | 173 | ||||
| -rw-r--r-- | internal/router/handlers/root.go | 3 | ||||
| -rw-r--r-- | internal/router/router.go | 36 | ||||
| -rw-r--r-- | main.go | 2 | ||||
| -rw-r--r-- | static/favicon-dark.svg | 16 | ||||
| -rw-r--r-- | static/favicon-light.svg | 16 | ||||
| -rw-r--r-- | static/favicon.ico | bin | 5694 -> 0 bytes | |||
| -rw-r--r-- | static/input.css | 125 | ||||
| -rw-r--r-- | views/layouts/general-page.html | 1026 | ||||
| -rw-r--r-- | views/pages/global-map.html | 18 | ||||
| -rw-r--r-- | views/pages/robots.txt | 4 | ||||
| -rw-r--r-- | views/pages/unsubscribe-page.html | 12 |
23 files changed, 1189 insertions, 543 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml index d89cc76..97afdf7 100644 --- a/.github/workflows/build-and-deploy-prod.yml +++ b/.github/workflows/build-and-deploy-prod.yml @@ -80,4 +80,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_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 }}" -e saya_today_google_site_verification="${{ secrets.GOOGLE_SITE_VERIFICATION }}" + 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_s3_access_key_id="${{ secrets.S3_ACCESS_KEY_ID }}" -e saya_today_web_s3_secret_access_key="${{ secrets.S3_SECRET_ACCESS_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 }}" -e saya_today_google_site_verification="${{ secrets.GOOGLE_SITE_VERIFICATION }}" -e saya_today_yandex_verification="${{ secrets.YANDEX_VERIFICATION }}" diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml index e699856..ffee091 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_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 }}" + 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_s3_access_key_id="${{ secrets.S3_ACCESS_KEY_ID }}" -e saya_today_web_s3_secret_access_key="${{ secrets.S3_SECRET_ACCESS_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 }}" diff --git a/config/config.go b/config/config.go index 536a333..0b1570a 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ import ( type Config struct { LogLevel slog.Level `json:"LogLevel" yaml:"logLevel" validate:"required"` + Endpoint EndpointConfig `json:"Endpoint" yaml:"endpoint" 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"` @@ -25,12 +26,90 @@ type Config struct { AllowOrigins []string `json:"AllowOrigins" yaml:"allowOrigins"` } +type EndpointConfig struct { + Type string `json:"Type" yaml:"type" validate:"required,oneof=http unix"` + Config any `json:"Config" yaml:"config" validate:"required"` +} + +func (ec *EndpointConfig) UnmarshalJSON(data []byte) error { + var tmp struct { + Type string `json:"Type"` + Config json.RawMessage `json:"Config"` + } + + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + + ec.Type = tmp.Type + + switch tmp.Type { + case "http": + var httpConfig HttpConfig + if err := json.Unmarshal(tmp.Config, &httpConfig); err != nil { + return fmt.Errorf("unmarshal HttpConfig: %w", err) + } + ec.Config = &httpConfig + case "unix": + var unixConfig UnixConfig + if err := json.Unmarshal(tmp.Config, &unixConfig); err != nil { + return fmt.Errorf("unmarshal UnixConfig: %w", err) + } + ec.Config = &unixConfig + default: + return fmt.Errorf("unsupported storage type: %s", tmp.Type) + } + + return nil +} + +func (ec *EndpointConfig) UnmarshalYAML(value *yaml.Node) error { + var tmp struct { + Type string `yaml:"type"` + Config yaml.Node `yaml:"config"` + } + + if err := value.Decode(&tmp); err != nil { + return err + } + + ec.Type = tmp.Type + + switch tmp.Type { + case "http": + var httpConfig HttpConfig + if err := tmp.Config.Decode(&httpConfig); err != nil { + return fmt.Errorf("unmarshal HttpConfig: %w", err) + } + ec.Config = &httpConfig + case "unix": + var unixConfig UnixConfig + if err := tmp.Config.Decode(&unixConfig); err != nil { + return fmt.Errorf("unmarshal UnixConfig: %w", err) + } + ec.Config = &unixConfig + default: + return fmt.Errorf("unsupported storage type: %s", tmp.Type) + } + + return nil +} + +type HttpConfig struct { + ListenOn string `json:"ListenOn" yaml:"listenOn" validate:"required"` +} + +type UnixConfig struct { + Path string `json:"Path" yaml:"path" validate:"required"` + Chmod string `json:"Chmod" yaml:"chmod" validate:"oneof=0600 0660 0666"` +} + type BlogPagesConfig struct { Storage StorageConfig `json:"Storage" yaml:"storage" validate:"required"` } type StorageConfig struct { - Type string `json:"type" yaml:"type,oneof=b2 s3"` + Type string `json:"Type" yaml:"type" validate:"required,oneof=b2 s3"` Config any `json:"Config" yaml:"config" validate:"required"` } @@ -159,6 +238,7 @@ type TriggerConfig struct { type MetaConfig struct { GoogleSiteVerification string `json:"GoogleSiteVerification" yaml:"googleSiteVerification"` + YandexVerification string `json:"YandexVerification" yaml:"yandexVerification"` } type PhotoStorageConfig struct { @@ -202,6 +282,10 @@ func InitConfig(path string) (*Config, error) { return nil, err } + if config.Endpoint.Type == "unix" && config.Endpoint.Config.(*UnixConfig).Chmod == "" { + config.Endpoint.Config.(*UnixConfig).Chmod = "0660" + } + validate := validator.New(validator.WithRequiredStructEnabled()) if err := validate.Struct(config); err != nil { return nil, err diff --git a/config/config.local.yaml b/config/config.local.yaml index 7769438..d3e2e33 100644 --- a/config/config.local.yaml +++ b/config/config.local.yaml @@ -1,22 +1,30 @@ logLevel: debug +endpoint: + type: http + config: + listenOn: ":3000" blogPages: storage: - type: b2 + type: s3 config: - bucketName: sayana-pages - region: eu-central-003 + bucketName: sayauz-pages + region: kz1 prefix: "stage-" - keyID: "${B2_KEY_ID}" - applicationKey: "${B2_APPLICATION_KEY}" + endpoint: "https://storage.yandexcloud.kz" + usePathStyle: true + accessKeyID: "${S3_ACCESS_KEY_ID}" + secretAccessKey: "${S3_SECRET_ACCESS_KEY}" factGiver: storage: - type: b2 + type: s3 config: - bucketName: sayana-pages - region: eu-central-003 + bucketName: sayauz-pages + region: kz1 prefix: "" - keyID: "${B2_KEY_ID}" - applicationKey: "${B2_APPLICATION_KEY}" + endpoint: "https://storage.yandexcloud.kz" + usePathStyle: true + accessKeyID: "${S3_ACCESS_KEY_ID}" + secretAccessKey: "${S3_SECRET_ACCESS_KEY}" factsFileName: "facts-*.txt" localePath: ./locale/ availableLanguages: diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 91f606f..e5785f8 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -1,22 +1,31 @@ logLevel: info +endpoint: + type: unix + config: + path: "/etc/sayauz/prod.socket" + chmod: '0666' blogPages: storage: - type: b2 + type: s3 config: - bucketName: sayana-pages - region: eu-central-003 + bucketName: sayauz-pages + region: kz1 prefix: "prod-" - keyID: "${B2_KEY_ID}" - applicationKey: "${B2_APPLICATION_KEY}" + endpoint: "https://storage.yandexcloud.kz" + usePathStyle: true + accessKeyID: "${S3_ACCESS_KEY_ID}" + secretAccessKey: "${S3_SECRET_ACCESS_KEY}" factGiver: storage: - type: b2 + type: s3 config: - bucketName: sayana-pages - region: eu-central-003 + bucketName: sayauz-pages + region: kz1 prefix: "" - keyID: "${B2_KEY_ID}" - applicationKey: "${B2_APPLICATION_KEY}" + endpoint: "https://storage.yandexcloud.kz" + usePathStyle: true + accessKeyID: "${S3_ACCESS_KEY_ID}" + secretAccessKey: "${S3_SECRET_ACCESS_KEY}" factsFileName: "facts-*.txt" localePath: ./locale/ availableLanguages: @@ -47,6 +56,7 @@ mail: canonicalEndpoint: "https://${FQDN}" meta: googleSiteVerification: "${GOOGLE_SITE_VERIFICATION}" + yandexVerification: "${YANDEX_VERIFICATION}" photoStorage: full: baseUrl: https://storage.yandexcloud.kz/sayauz-photos/%s diff --git a/config/config.stage.yaml b/config/config.stage.yaml index 3f1381b..c2e337b 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -1,22 +1,31 @@ logLevel: debug +endpoint: + type: unix + config: + path: "/etc/sayauz/stage.socket" + chmod: '0666' blogPages: storage: - type: b2 + type: s3 config: - bucketName: sayana-pages - region: eu-central-003 + bucketName: sayauz-pages + region: kz1 prefix: "stage-" - keyID: "${B2_KEY_ID}" - applicationKey: "${B2_APPLICATION_KEY}" + endpoint: "https://storage.yandexcloud.kz" + usePathStyle: true + accessKeyID: "${S3_ACCESS_KEY_ID}" + secretAccessKey: "${S3_SECRET_ACCESS_KEY}" factGiver: storage: - type: b2 + type: s3 config: - bucketName: sayana-pages - region: eu-central-003 + bucketName: sayauz-pages + region: kz1 prefix: "" - keyID: "${B2_KEY_ID}" - applicationKey: "${B2_APPLICATION_KEY}" + endpoint: "https://storage.yandexcloud.kz" + usePathStyle: true + accessKeyID: "${S3_ACCESS_KEY_ID}" + secretAccessKey: "${S3_SECRET_ACCESS_KEY}" factsFileName: "facts-*.txt" localePath: ./locale/ availableLanguages: diff --git a/deploy/inventory.yml b/deploy/inventory.yml index d3acab8..0a63140 100644 --- a/deploy/inventory.yml +++ b/deploy/inventory.yml @@ -8,6 +8,7 @@ all: saya_today_web_hostname: sayauz-prod saya_today_web_ipv4_address: 172.16.0.17 saya_today_web_listen_address: saya.uz + saya_today_web_unix_sockets_path: '/etc/sayauz' stage: hosts: @@ -17,3 +18,4 @@ all: saya_today_web_hostname: sayauz-stage saya_today_web_ipv4_address: 172.16.0.18 saya_today_web_listen_address: stage.saya.uz + saya_today_web_unix_sockets_path: '/etc/sayauz' diff --git a/deploy/playbook.yml b/deploy/playbook.yml index 8fb9088..ae3e936 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -2,9 +2,27 @@ - name: Deploy saya-today-web Docker Container hosts: all remote_user: svc_github + + vars: + docker_volumes: + - "{{ saya_today_web_name }}-volume:/data:rw" tasks: - - name: Create a volume + - name: Initialize Unix socket volume + when: >- + saya_today_web_unix_sockets_path is defined and + saya_today_web_unix_sockets_path is string and + saya_today_web_unix_sockets_path | length > 0 + block: + - name: Ensure Unix socket volume exists + community.docker.docker_volume: + name: "sayana_sockets" + - name: Include Unix socket volume in docker volumes list + ansible.builtin.set_fact: + docker_volumes: >- + {{ docker_volumes + ["sayana_sockets:" ~ saya_today_web_unix_sockets_path ~ ":rw"] }} + + - name: Create a data volume community.docker.docker_volume: name: "{{ saya_today_web_name }}-volume" @@ -14,8 +32,8 @@ hostname: "{{ saya_today_web_hostname }}" image: "ghcr.io/sayaandy/saya-today-web:{{ saya_today_web_tag }}" env: - B2_KEY_ID: "{{ saya_today_web_b2_key_id }}" - B2_APPLICATION_KEY: "{{ saya_today_web_b2_application_key }}" + S3_ACCESS_KEY_ID: "{{ saya_today_web_s3_access_key_id }}" + S3_SECRET_ACCESS_KEY: "{{ saya_today_web_s3_secret_access_key }}" ENVIRONMENT: "{{ saya_today_web_environment }}" AUTH_SALT: "{{ saya_today_web_auth_salt }}" MAIL_HOST: "{{ saya_today_web_mail_host }}" @@ -25,12 +43,12 @@ MAIL_SALT: "{{ saya_today_web_mail_salt }}" FQDN: "{{ saya_today_web_listen_address }}" GOOGLE_SITE_VERIFICATION: "{{ saya_today_google_site_verification | default('') }}" + YANDEX_VERIFICATION: "{{ saya_today_yandex_verification | default('') }}" network_mode: caddy networks: - name: caddy ipv4_address: "{{ saya_today_web_ipv4_address }}" - volumes: - - "{{ saya_today_web_name }}-volume:/data:rw" + volumes: "{{ docker_volumes }}" restart_policy: unless-stopped no_log: true ... @@ -19,7 +19,6 @@ require ( require ( github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect @@ -2,78 +2,40 @@ github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQ github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= -github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 h1:mxuT1xE+dI54NW3RkNjP8DUT5HXqbkiAFvfdyDFwE5c= github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -91,8 +53,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc= -github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU= github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -103,8 +63,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= -github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= -github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -113,8 +71,6 @@ 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/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.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -127,20 +83,12 @@ 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-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk= -github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA= -github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -152,8 +100,6 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV 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/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= -github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -168,17 +114,10 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/blog/index.go b/internal/blog/index.go new file mode 100644 index 0000000..861a90e --- /dev/null +++ b/internal/blog/index.go @@ -0,0 +1,32 @@ +package blog + +import "time" + +const IndexFileName = "index.json" + +const IndexSchemaVersion = 1 + +type IndexEntry struct { + Link string `json:"link"` + ModifiedTime time.Time `json:"modifiedTime"` + Title string `json:"title"` + ShortDescription string `json:"shortDescription"` + ActionDate string `json:"actionDate"` + PublishedTime time.Time `json:"publishedTime"` + Thumbnail string `json:"thumbnail"` + Tags []string `json:"tags"` + Geolocation string `json:"geolocation"` + Medley string `json:"medley,omitempty"` + MedleyPart int `json:"medleyPart,omitempty"` +} + +type IndexCategory struct { + GeneratedAt time.Time `json:"generatedAt"` + Pages []IndexEntry `json:"pages"` +} + +type Index struct { + SchemaVersion int `json:"schemaVersion"` + GeneratedAt time.Time `json:"generatedAt"` + Categories map[string]IndexCategory `json:"categories"` +} diff --git a/internal/blog/s3.go b/internal/blog/s3.go index 86a6463..bbd5238 100644 --- a/internal/blog/s3.go +++ b/internal/blog/s3.go @@ -2,10 +2,15 @@ package blog import ( "context" + "encoding/json" + "errors" "fmt" "io" + "log/slog" + "net/url" "slices" "strings" + "sync" "time" "github.com/SayaAndy/saya-today-web/config" @@ -14,8 +19,11 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" ) +const s3ScanConcurrency = 32 + type S3Client struct { prefix string bucketName string @@ -50,6 +58,7 @@ func NewS3Client(cfg *config.StorageConfig) (Client, error) { } s3Opts = append(s3Opts, func(o *s3.Options) { o.UsePathStyle = s3cfg.UsePathStyle + o.DisableLogOutputChecksumValidationSkipped = true }) s3cl := s3.NewFromConfig(awsCfg, s3Opts...) @@ -58,79 +67,195 @@ func NewS3Client(cfg *config.StorageConfig) (Client, error) { } func (c *S3Client) Scan(prefix string) ([]*Page, error) { - pages := []*Page{} + pages, err := c.scanFromIndex(prefix) + if err == nil { + return pages, nil + } - fullPrefix := c.prefix + prefix - input := &s3.ListObjectsV2Input{ + var nsk *s3types.NoSuchKey + if !errors.As(err, &nsk) { + return nil, err + } + + slog.Warn("index.json missing, falling back to listing", slog.String("prefix", c.prefix)) + return c.scanByListing(prefix) +} + +func (c *S3Client) scanFromIndex(prefix string) ([]*Page, error) { + out, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{ Bucket: aws.String(c.bucketName), - Prefix: aws.String(fullPrefix), + Key: aws.String(IndexFileName), + }) + if err != nil { + return nil, fmt.Errorf("get index.json: %w", err) + } + defer out.Body.Close() + + raw, err := io.ReadAll(out.Body) + if err != nil { + return nil, fmt.Errorf("read index.json: %w", err) + } + + var idx Index + if err := json.Unmarshal(raw, &idx); err != nil { + return nil, fmt.Errorf("unmarshal index.json: %w", err) + } + + wantLang := "" + if i := strings.Index(prefix, "/"); i > 0 { + wantLang = prefix[:i] + } + + fullPrefix := c.prefix + prefix + pages := make([]*Page, 0) + for catKey, cat := range idx.Categories { + lang, ok := strings.CutPrefix(catKey, c.prefix) + if !ok { + continue + } + if wantLang != "" && wantLang != lang { + continue + } + for _, e := range cat.Pages { + if !strings.HasPrefix(e.Link, fullPrefix) { + continue + } + linkParts := strings.Split(e.Link, "/") + nameParts := strings.Split(linkParts[len(linkParts)-1], ".") + fileName := strings.Join(nameParts[:len(nameParts)-1], ".") + pages = append(pages, &Page{ + Link: e.Link, + FileName: fileName, + Lang: lang, + ModifiedTime: e.ModifiedTime, + Metadata: &frontmatter.Metadata{ + Title: e.Title, + ShortDescription: e.ShortDescription, + ActionDate: e.ActionDate, + PublishedTime: e.PublishedTime, + Thumbnail: e.Thumbnail, + Tags: e.Tags, + Geolocation: e.Geolocation, + Medley: e.Medley, + MedleyPart: e.MedleyPart, + }, + }) + } } + return pages, nil +} - paginator := s3.NewListObjectsV2Paginator(c.s3cl, input) +func (c *S3Client) scanByListing(prefix string) ([]*Page, error) { + fullPrefix := c.prefix + prefix + + type candidate struct { + key string + lastModified time.Time + } + var candidates []candidate + paginator := s3.NewListObjectsV2Paginator(c.s3cl, &s3.ListObjectsV2Input{ + Bucket: aws.String(c.bucketName), + Prefix: aws.String(fullPrefix), + }) for paginator.HasMorePages() { output, err := paginator.NextPage(context.Background()) if err != nil { return nil, fmt.Errorf("list S3 objects: %w", err) } - for _, obj := range output.Contents { key := aws.ToString(obj.Key) - if !strings.HasSuffix(key, ".md") { continue } + candidates = append(candidates, candidate{key, aws.ToTime(obj.LastModified)}) + } + } + + pages := make([]*Page, len(candidates)) + sem := make(chan struct{}, s3ScanConcurrency) + var wg sync.WaitGroup + var firstErr error + var errMu sync.Mutex + + for i, cand := range candidates { + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() head, err := c.s3cl.HeadObject(context.Background(), &s3.HeadObjectInput{ Bucket: aws.String(c.bucketName), - Key: aws.String(key), + Key: aws.String(cand.key), }) if err != nil { - return nil, fmt.Errorf("head S3 object %s: %w", key, err) + errMu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("head S3 object %s: %w", cand.key, err) + } + errMu.Unlock() + return } if head.ContentType == nil || !strings.Contains(*head.ContentType, "text/markdown") { - continue + return } - meta := head.Metadata if meta["title"] == "" { - continue + return } publishedTime, err := time.Parse(time.RFC3339, meta["published-time"]) if err != nil { - return nil, fmt.Errorf("failed to parse published time metadata field: %w", err) + errMu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("failed to parse published time metadata field: %w", err) + } + errMu.Unlock() + return } - linkParts := strings.Split(key, "/") + linkParts := strings.Split(cand.key, "/") nameParts := strings.Split(linkParts[len(linkParts)-1], ".") fileName := strings.Join(nameParts[:len(nameParts)-1], ".") + lang, _ := strings.CutPrefix(linkParts[0], c.prefix) tags := strings.Split(meta["tags"], ",") slices.Sort(tags) - lang, _ := strings.CutPrefix(linkParts[0], c.prefix) + title, _ := url.QueryUnescape(meta["title"]) + shortDescription, _ := url.QueryUnescape(meta["short-description"]) + thumbnail, _ := url.QueryUnescape(meta["thumbnail"]) - pages = append(pages, &Page{ - Link: key, + pages[i] = &Page{ + Link: cand.key, FileName: fileName, Lang: lang, - ModifiedTime: aws.ToTime(obj.LastModified), + ModifiedTime: cand.lastModified, Metadata: &frontmatter.Metadata{ - Title: meta["title"], - ShortDescription: meta["short-description"], + Title: title, + ShortDescription: shortDescription, ActionDate: meta["action-date"], PublishedTime: publishedTime, - Thumbnail: meta["thumbnail"], + Thumbnail: thumbnail, Tags: tags, Geolocation: meta["geolocation"], }, - }) - } + } + }) } + wg.Wait() - return pages, nil + if firstErr != nil { + return nil, firstErr + } + + out := pages[:0] + for _, p := range pages { + if p != nil { + out = append(out, p) + } + } + return out, nil } func (c *S3Client) ReadAll(path string) ([]byte, error) { diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go index a8890fa..6a011e4 100644 --- a/internal/router/handlers/root.go +++ b/internal/router/handlers/root.go @@ -50,6 +50,9 @@ func (r *RootHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lan if supplements.Meta.GoogleSiteVerification != "" { meta = append(meta, router.MetaField{Name: "google-site-verification", Content: supplements.Meta.GoogleSiteVerification}) } + if supplements.Meta.YandexVerification != "" { + meta = append(meta, router.MetaField{Name: "yandex-verification", Content: supplements.Meta.YandexVerification}) + } return meta, nil } diff --git a/internal/router/router.go b/internal/router/router.go index 5230e74..65e35a4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -7,8 +7,11 @@ import ( "fmt" "html/template" "log/slog" + "net" "net/url" + "os" "slices" + "strconv" "strings" "time" @@ -112,6 +115,7 @@ type Router struct { templatedRoutes map[string]map[string]Route templatedPathMatcher *PathMatcher canonicalEndpoint string + endpoint config.EndpointConfig } func NewRouter(cfg *config.Config) (*Router, error) { @@ -256,7 +260,7 @@ func NewRouter(cfg *config.Config) (*Router, error) { templatedRoutes := make(map[string]map[string]Route) templatedPathMatcher := NewPathMatcher() - return &Router{supplements, app, templatedRoutes, templatedPathMatcher, cfg.CanonicalEndpoint}, nil + return &Router{supplements, app, templatedRoutes, templatedPathMatcher, cfg.CanonicalEndpoint, cfg.Endpoint}, nil } func (r *Router) InitRoutes() (err error) { @@ -305,6 +309,8 @@ func (r *Router) InitRoutes() (err error) { cacheKey = fmt.Sprintf("%s.full-page.%s", method, trimmedPath) case ByUrlAndQuery: cacheKey = fmt.Sprintf("%s.full-page.%s.%s", method, trimmedPath, queryString) + case Disabled: + c.Set("Cache-Control", "no-store, no-cache, must-revalidate") } defaultMap := fiber.Map{ @@ -367,6 +373,7 @@ func (r *Router) InitRoutes() (err error) { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("unknown segment '%s'", part)) } + c.Set("Cache-Control", "no-store, no-cache, must-revalidate") err := r.generalPageSegment(c, part) return err }) @@ -382,10 +389,30 @@ func (r *Router) InitRoutes() (err error) { 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) +func (r *Router) Listen() error { + switch r.endpoint.Type { + case "unix": + unixConfig := r.endpoint.Config.(*config.UnixConfig) + endpoint, _ := strings.CutPrefix(unixConfig.Path, "unix://") + ln, err := net.Listen("unix", endpoint) + if err != nil { + return fmt.Errorf("error while initializing unix listener: %w", err) + } + chmod, _ := strconv.ParseUint(unixConfig.Chmod[1:], 8, 32) + os.Chmod(unixConfig.Path, os.FileMode(chmod)) + if err := r.app.Listener(ln); err != nil { + return fmt.Errorf("error while running fiber server: %w", err) + } + case "http": + httpConfig := r.endpoint.Config.(*config.HttpConfig) + fmt.Print(httpConfig) + if err := r.app.Listen(httpConfig.ListenOn); err != nil { + return fmt.Errorf("error while running fiber server: %w", err) + } + default: + return fmt.Errorf("error with initializing fiber server: invalid endpoint type (supported are unix and http)") } + return nil } @@ -409,6 +436,7 @@ func (r *Router) Close() (err error) { } slog.Debug("closing page cache") r.supplements.PageCache.Close() + return errors.Join(allErrors...) } @@ -47,7 +47,7 @@ func main() { routerShutdown := make(chan struct{}, 1) go func() { - if err := app.Listen(":3000"); err != nil { + if err := app.Listen(); 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())) diff --git a/static/favicon-dark.svg b/static/favicon-dark.svg new file mode 100644 index 0000000..f09a1bf --- /dev/null +++ b/static/favicon-dark.svg @@ -0,0 +1,16 @@ +<svg role="img" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" id="Lutris--Streamline-Simple-Icons" height="120" width="120"> + <desc> + Lutris Streamline Icon: https://streamlinehq.com + </desc> + <defs> + <linearGradient gradientTransform="rotate(150, 0.5, 0.5)" x1="50%" y1="0%" x2="50%" y2="100%" id="gradient"> + <stop stop-color="hsl(31.4, 100%, 70.8%)" stop-opacity="1" offset="0%"></stop><stop stop-color="hsl(17, 100%, 31%)" stop-opacity="1" offset="100%"></stop> + </linearGradient> + <filter id="filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feTurbulence type="fractalNoise" baseFrequency="0.005 0.003" numOctaves="1" seed="2" stitchTiles="stitch" x="0%" y="0%" width="100%" height="100%" result="turbulence"></feTurbulence> + <feGaussianBlur stdDeviation="20 0" x="0%" y="0%" width="100%" height="100%" in="turbulence" edgeMode="duplicate" result="blur"></feGaussianBlur> + <feBlend mode="color-dodge" x="0%" y="0%" width="100%" height="100%" in="SourceGraphic" in2="blur" result="blend"></feBlend> + </filter> + </defs> + <path d="m106.155 94.45 0.005 -0.01c-6.465 16.215 -26.09 26.16 -47.235 25.525C26.5 119.965 0 92.4 0 59.53S26.38 0.005 58.925 0.005c8.965 0 17.465 2.03 25.075 5.65 0.405 -0.885 1.355 -2.72 2.255 -2.785 1.19 -0.085 1.87 0.685 2.63 1.545 0.77 0.86 2.3 2.145 2.3 2.145s6.965 -2.405 14.775 1.885c7.815 4.29 8.915 5.58 10.45 8.58 0.76 1.505 0.975 4.145 1 6.41a3.98 3.98 0 0 0 -0.35 -0.015c-2.48 0 -4.8 2.275 -4.8 5.4 0 1.315 0.41 2.48 1.075 3.39l-0.05 0.035a7.525 7.525 0 0 0 -0.66 0.05 93.52 93.52 0 0 0 -1.945 -0.71 12.65 12.65 0 0 1 -4.1 -2.36 7.01 7.01 0 0 0 -5.98 -10.56c-1.915 0 -3.65 0.78 -4.91 2.05 -2.36 -1.355 -5.87 -2.41 -12.635 -2.825l-2.035 -0.055c-11.41 0.06 -18.055 1.395 -29.895 6.505 -3.015 1.415 -6.03 3.075 -8.925 5.005 -2.115 1.5 -3.195 3.35 -3.545 5.685a6.63 6.63 0 0 0 6.15 6.865h0.210c6.35 0.3 10.195 9.95 10.315 12.485 0.02 0.25 0.02 0.115 0.015 0.4 -0.16 3.635 -1.85 6.335 -5.44 6.23a6.155 6.155 0 0 1 -4.88 -2.47c-0.315 -0.385 -0.515 -0.86 -0.795 -1.27 -3.33 -5.405 -8.66 -6.80 -13.855 -7.615 -2.19 -0.34 -5.365 -0.61 -6.55 1.25a41.4 41.4 0 0 0 -2.885 15.315c-0.1 25.18 20.205 45.59 45.13 45.59 12.875 0 26.745 -4.76 34.965 -13.5l-0.175 0.15c-8.86 7.365 -23.3 9.705 -30.135 9.705 -21.51 0 -39.09 -16.16 -39.09 -37.89 0 -6.38 1.44 -11.98 4.07 -16.8 2.475 0.915 4.735 2.415 6.4 5.11a1.2 1.2 0 0 0 0.065 0.1050c0.32 0.46 0.555 0.985 0.91 1.42 2.12 2.62 4.405 3.29 6.71 3.4h0.05c2.15 0.065 3.84 -0.6 5.12 -1.71 1.735 -1.5 2.75 -3.95 2.885 -6.91v-0.07c0.01 -0.425 0 -0.265 -0.02 -0.56 -0.12 -1.88 -1.665 -6.59 -4.53 -10.135 -1.33 -1.655 -2.935 -3.035 -4.75 -3.87l0.6 -0.37c3.78 -2.285 11.82 -4.885 22.96 -3.19 5.65 0.865 10.275 2.095 17.415 4.395 8.285 2.67 12.895 6.395 19.27 7.135 0.75 0.085 1.505 0.09 2.25 0.015 2.05 5.645 3.17 11.75 3.17 18.105 0 10.34 -2.95 19.975 -8.055 28.1zm9.735 -61.37s-0.575 1.005 -1.82 1.61c-0.515 0.25 -1.41 -0.375 -2.25 0.5 -1.795 3.63 2.58 6.66 4.615 6.575 2.04 -0.085 3.65 -2.16 3.56 -3.965 -0.085 -2.79 -4.1 -4.72 -4.1 -4.72zm1.17 -7.16c1.275 0 2.31 1.3 2.31 2.9 0 1.6 -1.035 2.9 -2.31 2.9 -1.27 0 -2.3 -1.3 -2.3 -2.9 0 -1.6 1.03 -2.9 2.3 -2.9zm-16.46 -4.755c2.46 0 4.45 2.015 4.45 4.5a4.475 4.475 0 0 1 -4.45 4.49 4.475 4.475 0 0 1 -4.45 -4.495c0 -2.48 1.995 -4.495 4.45 -4.495z" fill="url(#gradient)" filter="url(#filter)" stroke-width="5"></path> +</svg> diff --git a/static/favicon-light.svg b/static/favicon-light.svg new file mode 100644 index 0000000..e7ed6eb --- /dev/null +++ b/static/favicon-light.svg @@ -0,0 +1,16 @@ +<svg role="img" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" id="Lutris--Streamline-Simple-Icons" height="120" width="120"> + <desc> + Lutris Streamline Icon: https://streamlinehq.com + </desc> + <defs> + <linearGradient gradientTransform="rotate(150, 0.5, 0.5)" x1="50%" y1="0%" x2="50%" y2="100%" id="gradient"> + <stop stop-color="hsl(30.1, 100%, 44.1%)" stop-opacity="1" offset="0%"></stop><stop stop-color="hsl(23.1, 90.7%, 25.3%)" stop-opacity="1" offset="100%"></stop> + </linearGradient> + <filter id="filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feTurbulence type="fractalNoise" baseFrequency="0.005 0.003" numOctaves="1" seed="2" stitchTiles="stitch" x="0%" y="0%" width="100%" height="100%" result="turbulence"></feTurbulence> + <feGaussianBlur stdDeviation="20 0" x="0%" y="0%" width="100%" height="100%" in="turbulence" edgeMode="duplicate" result="blur"></feGaussianBlur> + <feBlend mode="color-dodge" x="0%" y="0%" width="100%" height="100%" in="SourceGraphic" in2="blur" result="blend"></feBlend> + </filter> + </defs> + <path d="m106.155 94.45 0.005 -0.01c-6.465 16.215 -26.09 26.16 -47.235 25.525C26.5 119.965 0 92.4 0 59.53S26.38 0.005 58.925 0.005c8.965 0 17.465 2.03 25.075 5.65 0.405 -0.885 1.355 -2.72 2.255 -2.785 1.19 -0.085 1.87 0.685 2.63 1.545 0.77 0.86 2.3 2.145 2.3 2.145s6.965 -2.405 14.775 1.885c7.815 4.29 8.915 5.58 10.45 8.58 0.76 1.505 0.975 4.145 1 6.41a3.98 3.98 0 0 0 -0.35 -0.015c-2.48 0 -4.8 2.275 -4.8 5.4 0 1.315 0.41 2.48 1.075 3.39l-0.05 0.035a7.525 7.525 0 0 0 -0.66 0.05 93.52 93.52 0 0 0 -1.945 -0.71 12.65 12.65 0 0 1 -4.1 -2.36 7.01 7.01 0 0 0 -5.98 -10.56c-1.915 0 -3.65 0.78 -4.91 2.05 -2.36 -1.355 -5.87 -2.41 -12.635 -2.825l-2.035 -0.055c-11.41 0.06 -18.055 1.395 -29.895 6.505 -3.015 1.415 -6.03 3.075 -8.925 5.005 -2.115 1.5 -3.195 3.35 -3.545 5.685a6.63 6.63 0 0 0 6.15 6.865h0.210c6.35 0.3 10.195 9.95 10.315 12.485 0.02 0.25 0.02 0.115 0.015 0.4 -0.16 3.635 -1.85 6.335 -5.44 6.23a6.155 6.155 0 0 1 -4.88 -2.47c-0.315 -0.385 -0.515 -0.86 -0.795 -1.27 -3.33 -5.405 -8.66 -6.80 -13.855 -7.615 -2.19 -0.34 -5.365 -0.61 -6.55 1.25a41.4 41.4 0 0 0 -2.885 15.315c-0.1 25.18 20.205 45.59 45.13 45.59 12.875 0 26.745 -4.76 34.965 -13.5l-0.175 0.15c-8.86 7.365 -23.3 9.705 -30.135 9.705 -21.51 0 -39.09 -16.16 -39.09 -37.89 0 -6.38 1.44 -11.98 4.07 -16.8 2.475 0.915 4.735 2.415 6.4 5.11a1.2 1.2 0 0 0 0.065 0.1050c0.32 0.46 0.555 0.985 0.91 1.42 2.12 2.62 4.405 3.29 6.71 3.4h0.05c2.15 0.065 3.84 -0.6 5.12 -1.71 1.735 -1.5 2.75 -3.95 2.885 -6.91v-0.07c0.01 -0.425 0 -0.265 -0.02 -0.56 -0.12 -1.88 -1.665 -6.59 -4.53 -10.135 -1.33 -1.655 -2.935 -3.035 -4.75 -3.87l0.6 -0.37c3.78 -2.285 11.82 -4.885 22.96 -3.19 5.65 0.865 10.275 2.095 17.415 4.395 8.285 2.67 12.895 6.395 19.27 7.135 0.75 0.085 1.505 0.09 2.25 0.015 2.05 5.645 3.17 11.75 3.17 18.105 0 10.34 -2.95 19.975 -8.055 28.1zm9.735 -61.37s-0.575 1.005 -1.82 1.61c-0.515 0.25 -1.41 -0.375 -2.25 0.5 -1.795 3.63 2.58 6.66 4.615 6.575 2.04 -0.085 3.65 -2.16 3.56 -3.965 -0.085 -2.79 -4.1 -4.72 -4.1 -4.72zm1.17 -7.16c1.275 0 2.31 1.3 2.31 2.9 0 1.6 -1.035 2.9 -2.31 2.9 -1.27 0 -2.3 -1.3 -2.3 -2.9 0 -1.6 1.03 -2.9 2.3 -2.9zm-16.46 -4.755c2.46 0 4.45 2.015 4.45 4.5a4.475 4.475 0 0 1 -4.45 4.49 4.475 4.475 0 0 1 -4.45 -4.495c0 -2.48 1.995 -4.495 4.45 -4.495z" fill="url(#gradient)" filter="url(#filter)" stroke-width="5"></path> +</svg> diff --git a/static/favicon.ico b/static/favicon.ico Binary files differdeleted file mode 100644 index 33eb226..0000000 --- a/static/favicon.ico +++ /dev/null diff --git a/static/input.css b/static/input.css index 75482d8..5d8de42 100644 --- a/static/input.css +++ b/static/input.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Gentium+Plus:ital,wght@0,400;0,700;1,400;1,700&family=M+PLUS+Rounded+1c:wght@100;300;400;500;700;800;900&display=swap'); - @import "tailwindcss"; @plugin "@tailwindcss/forms"; @@ -48,7 +46,8 @@ html { @utility bg-paper-* { background-image: --value([image], [url]), var(--bg-paper); background-size: --value("auto", "cover", "contain"), 128px; - background-repeat: --value("repeat", "repeat-x", "repeat-y", "no-repeat"), repeat; + background-repeat: + --value("repeat", "repeat-x", "repeat-y", "no-repeat"), repeat; background-blend-mode: --value("overlay", "multiply", "normal"), normal; } @@ -145,7 +144,9 @@ body { --color-leaflet-text: var(--color-main-hard); --color-leaflet-link: var(--color-main-medium); --color-leaflet-link-hover: var(--color-main-soft); - --interlocked-hexagons-background: url('https://storage.yandexcloud.kz/sayauz-static/themes/lettuce/linen-fabric.webp'), url('https://storage.yandexcloud.kz/sayauz-static/themes/lettuce/lettuce-tiles.svg'); + --interlocked-hexagons-background: + url("https://storage.yandexcloud.kz/sayauz-static/themes/lettuce/linen-fabric.webp"), + url("https://storage.yandexcloud.kz/sayauz-static/themes/lettuce/lettuce-tiles.svg"); --interlocked-hexagons-background-repeat: repeat, repeat; --interlocked-hexagons-background-size: 11dvh, 20dvh; --squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23bbd095%22%2F%3E%3Cpath%20fill%3D%22%23f5ede1%22%20d%3D%22M0%200v6.77L6.72%200zm8.91%200L0%208.96v3.86L12.83%200zm6.11%200L0%2015.06v3.87L18.9%200zm6.11%200L0%2021.12v3.92L25%200zM25%200l25%2024.99v-3.87L28.87%200zm25%2024.99%2025%2024.98L100%2025%2075%200zm-50%20.05v3.87l21.13%2021.12H25l-25-25zm25%2024.99h3.87L50%2028.9v-3.87zm0%200L0%2075l25%2025%2025-24.99zM50%2075l25-24.98h-3.87L50%2071.14v3.87zm0%200v3.87L71.13%20100H75L50%2075.01zm25%2025h3.87L100%2078.88v-3.87zm25-24.99v-3.87L78.87%2050.03H75L100%2075zM31.1%200%2050%2018.93v-3.87L34.98%200h-3.87zm6.07%200L50%2012.82V8.96L41.04%200zm6.1%200L50%206.77V0h-6.72zM25%202.19%202.14%2025.04H25zm0%2022.85v22.8l22.81-22.8zM75%203.92l21.13%2021.12L75%2046.16%2053.87%2025.04zm0%202.19L56.06%2025.04l18.89%2018.88%2018.94-18.88L75%206.1zm0%203.92%2015.07%2015L75%2040.06%2059.98%2025.04zm0%202.18L62.17%2025.04%2075%2037.86l12.83-12.82L75%2012.2zm0%203.82%208.96%209L75%2033.95%2066.04%2025zm0%202.24-6.72%206.77L75%2031.76l6.77-6.72h-.05zM0%2031.09v3.87l15.02%2015.07h3.87zm50%200L31.1%2050.03h3.88L50%2034.96V31.1zM0%2037.2v3.87l8.91%208.96h3.92zm50%200L37.17%2050.03h3.87L50%2041.07zM0%2043.3v6.73h6.72zm50%200-6.72%206.73H50zm0%206.73v6.71l6.72-6.71zm8.96%200L50%2058.93v3.92l12.83-12.82zm6.06%200L50%2065.03v3.88l18.94-18.88zm16.09%200L100%2068.9v-3.87l-15.02-15zm6.11%200L100%2062.85v-3.92l-8.91-8.9zm6.06%200%206.72%206.71v-6.71zM75%2052.2%2052.19%2075H75zM75%2075v22.8L97.81%2075zM25%2053.9l21.13%2021.12L25%2096.13%203.87%2075.01zm0%202.2L6.1%2074.95l18.95%2018.88%2018.9-18.88L25%2056.08zm0%203.9%2015.02%2015.01L25%2090.03%209.98%2075zm0%202.19L12.17%2075%2025%2087.84%2037.83%2075%2025%2062.2zm0%203.87L33.96%2075%2025%2083.97%2016.04%2075zm0%202.23-6.72%206.72L25%2081.73l6.72-6.72L25%2068.3zm25%2012.83v3.87L65.02%20100h3.87zm50%200L81.1%20100h3.88L100%2084.99zm-50%206.06v3.86l8.96%208.96h3.87zm50%200L87.22%20100h3.87l8.91-8.96zm-50%206.1V100h6.72zm50%200L93.28%20100H100z%22%2F%3E%3C%2Fsvg%3E"); @@ -175,7 +176,44 @@ body { --color-leaflet-text: var(--color-background-dark); --color-leaflet-link: var(--color-background-dark); --color-leaflet-link-hover: var(--color-background-light); - --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: + 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: 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"); @@ -205,10 +243,10 @@ body { --color-leaflet-text: var(--color-background-dark); --color-leaflet-link: var(--color-background-dark); --color-leaflet-link-hover: var(--color-background-light); - --interlocked-hexagons-background: url('https://storage.yandexcloud.kz/sayauz-static/themes/ram/ram-background.webp'); + --interlocked-hexagons-background: url("https://storage.yandexcloud.kz/sayauz-static/themes/ram/ram-background.webp"); --interlocked-hexagons-background-repeat: repeat; --interlocked-hexagons-background-size: auto; - --squares-and-triangles-background: url('https://storage.yandexcloud.kz/sayauz-static/themes/ram/ram-sidebar.webp'); + --squares-and-triangles-background: url("https://storage.yandexcloud.kz/sayauz-static/themes/ram/ram-sidebar.webp"); --squares-and-triangles-background-repeat: repeat; --squares-and-triangles-background-width-coef: 5; --squares-and-triangles-background-height-coef: 15; @@ -219,7 +257,7 @@ body { --color-accent-deep: var(--color-indigo-800); --color-main-dark-hard: #343b58; --color-main-dark-medium: #414868; - --color-main-dark-soft: #6c6e75; + --color-main-dark-soft: #6c6e75; --color-main-light-hard: #e3e6ff; --color-main-light-medium: #d1d6ff; --color-main-light-soft: #c3caff; @@ -242,16 +280,18 @@ body { --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://storage.yandexcloud.kz/sayauz-static/themes/lilac/split-left.webp'); - --split-right-background: url('https://storage.yandexcloud.kz/sayauz-static/themes/lilac/split-right.webp'); + --split-left-background: url("https://storage.yandexcloud.kz/sayauz-static/themes/lilac/split-left.webp"); + --split-right-background: url("https://storage.yandexcloud.kz/sayauz-static/themes/lilac/split-right.webp"); } -a, .linklike { +a, +.linklike { color: var(--color-main-medium) !important; text-decoration: underline; } -a:hover, .linklike:hover { +a:hover, +.linklike:hover { color: var(--color-main-soft) !important; } @@ -271,7 +311,7 @@ a:hover, .linklike:hover { .crossable.disabled::before, .crossable.htmx-request::before { - content: ''; + content: ""; position: absolute; top: 0; left: 0; @@ -317,10 +357,18 @@ form.crossable.htmx-request input { .text-sidebar { color: var(--color-main-dark-soft); - text-shadow: -0.2rem 0 var(--color-main-dark-hard), 0 -0.2rem var(--color-main-dark-hard), 0.2rem 0rem var(--color-main-light-soft), 0 0.2rem var(--color-main-light-soft); + text-shadow: + -0.2rem 0 var(--color-main-dark-hard), + 0 -0.2rem var(--color-main-dark-hard), + 0.2rem 0rem var(--color-main-light-soft), + 0 0.2rem var(--color-main-light-soft); -webkit-text-stroke-color: var(--color-sidebar-stroke); -webkit-text-stroke-width: 0.1rem; - background: linear-gradient(135deg, color-mix(in srgb, var(--color-main-light-soft) 20%, transparent), color-mix(in srgb, var(--color-main-dark-soft) 20%, transparent)); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--color-main-light-soft) 20%, transparent), + color-mix(in srgb, var(--color-main-dark-soft) 20%, transparent) + ); } .text-secondary { @@ -338,11 +386,15 @@ form.crossable.htmx-request input { .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); + 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 0dvh 1dvh 1dvh -1dvh 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); @@ -404,7 +456,7 @@ form.crossable.htmx-request input { } .theme-icon.active::after { - content: ''; + content: ""; position: absolute; top: 0; left: 0; @@ -419,7 +471,8 @@ form.crossable.htmx-request input { position: relative; background: color-mix(in srgb, var(--color-main-soft) 75%, transparent); box-shadow: 0rem 0rem 1rem var(--color-main-hard); - border: 0.5rem solid color-mix(in srgb, var(--color-main-soft) 90%, transparent); + border: 0.5rem solid + color-mix(in srgb, var(--color-main-soft) 90%, transparent); backdrop-filter: blur(2px); top: 35%; left: 35%; @@ -435,7 +488,11 @@ form.crossable.htmx-request input { left: 0; width: 100%; height: 100%; - background: linear-gradient(135deg, var(--color-main-light-hard), var(--color-main-dark-hard)); + background: linear-gradient( + 135deg, + var(--color-main-light-hard), + var(--color-main-dark-hard) + ); opacity: 0.2; z-index: 10; content: ""; @@ -458,14 +515,16 @@ form.crossable.htmx-request input { --border-size: 0.5rem; } -.themed-button:active, .themed-button.active { +.themed-button:active, +.themed-button.active { border-top: var(--border-size) solid var(--color-main-dark-hard); border-left: var(--border-size) solid var(--color-main-dark-hard); border-bottom: var(--border-size) solid var(--color-main-light-hard); border-right: var(--border-size) solid var(--color-main-light-hard); } -.themed-button:active > *, .themed-button.active > * { +.themed-button:active > *, +.themed-button.active > * { transform: translateY(calc(var(--border-size) / 2)); } @@ -473,7 +532,10 @@ form.crossable.htmx-request input { --border-size: 0.25rem; background-image: var(--squares-and-triangles-background); background-repeat: var(--squares-and-triangles-background-repeat); - background-size: calc(var(--squares-and-triangles-background-width-coef) * 0.9rem) calc(var(--squares-and-triangles-background-height-coef) * 0.9rem); + background-size: calc( + var(--squares-and-triangles-background-width-coef) * 0.9rem + ) + calc(var(--squares-and-triangles-background-height-coef) * 0.9rem); } .themed-button.themed-button-like { @@ -486,7 +548,7 @@ form.crossable.htmx-request input { } .custom-svg-marker svg { - filter: drop-shadow(2px 2px 2px rgba(0,0,0,0.3)); + filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.3)); transition: transform 0.2s; } @@ -584,7 +646,8 @@ form.crossable.htmx-request input { } @container main (width >= 76rem) { - .grid-item, .grid-sizer { + .grid-item, + .grid-sizer { width: 18rem; } @@ -608,12 +671,20 @@ form.crossable.htmx-request input { } .goverlay { - background: color-mix(in srgb, var(--color-background-light) 50%, transparent) !important; + background: color-mix( + in srgb, + var(--color-background-light) 50%, + transparent + ) !important; backdrop-filter: blur(1rem); } .glightbox-clean .gslide-description { - background: color-mix(in srgb, var(--color-background-dark) 50%, transparent) !important; + background: color-mix( + in srgb, + var(--color-background-dark) 50%, + transparent + ) !important; backdrop-filter: blur(1rem); } diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index 93567d3..0bd3454 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -1,469 +1,725 @@ -<!DOCTYPE html> +<!doctype html> <html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - {{- range .Meta }} - <meta {{with .Name}}name="{{.}}" {{end}}{{with .Property}}property="{{.}}" {{end}}{{with .Content}}content="{{.}}"{{end}}> - {{- if eq .Property "og:description" }} - <meta name="description" content="{{.Content}}"> - {{- end }} - {{- if eq .Property "og:url" }} - <link rel="canonical" href="{{.Content}}"> - {{- end }} - {{- end }} - <link rel="stylesheet" href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/fontawesome.min.css"> - <link rel="stylesheet" href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/brands.min.css"> - <link rel="stylesheet" href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/solid.min.css"> - <link rel="stylesheet" href="{{ .StaticStorageBaseUrl }}/libs/leaflet/1.9.4/leaflet.css"> - <link rel="stylesheet" href="{{ .StaticStorageBaseUrl }}/libs/glightbox/3.3.0/css/glightbox.min.css"> - <link href="/output.css" rel="stylesheet"> - {{- with .LinkedData }} - <script type="application/ld+json"> - {{ . }} - </script> - {{- end }} -</head> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + {{- range .Meta }} + <meta + {{with + .Name}}name="{{.}}" + {{end}}{{with + .Property}}property="{{.}}" + {{end}}{{with + .Content}}content="{{.}}" + {{end}} + /> + {{- if eq .Property "og:description" }} + <meta name="description" content="{{.Content}}" /> + {{- end }} {{- if eq .Property "og:url" }} + <link rel="canonical" href="{{.Content}}" /> + {{- end }} {{- end }} + <link + rel="stylesheet" + href="{{ .StaticStorageBaseUrl }}/fonts/fonts.css" + /> + <link + rel="stylesheet" + href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/fontawesome.min.css" + /> + <link + rel="stylesheet" + href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/brands.min.css" + /> + <link + rel="stylesheet" + href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/solid.min.css" + /> + <link + rel="stylesheet" + href="{{ .StaticStorageBaseUrl }}/libs/leaflet/1.9.4/leaflet.css" + /> + <link + rel="stylesheet" + href="{{ .StaticStorageBaseUrl }}/libs/glightbox/3.3.0/css/glightbox.min.css" + /> + <link rel="stylesheet" href="/output.css" /> + <link + rel="icon" + href="/favicon-light.svg" + type="image/svg+xml" + media="(prefers-color-scheme: light)" + /> + <link + rel="icon" + href="/favicon-dark.svg" + type="image/svg+xml" + media="(prefers-color-scheme: dark)" + /> + {{- with .LinkedData }} + <script type="application/ld+json"> + {{ . }} + </script> + {{- end }} + </head> -<body data-theme="ram" class="font-m-plus text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh flex flex-row"> + <body + data-theme="ram" + class="font-m-plus text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh flex flex-row" + > + <script> + let map = null; + </script> - <script> - let map = null; - </script> + <script> + function changeUrl(path, toAppend = false) { + const urlParams = new URLSearchParams(window.location.search); + newPathname = location.pathname.endsWith("/") + ? location.pathname.slice(0, -1) + : location.pathname; + while (path.slice(-3) == "/.." && toAppend) { + newPathname = newPathname.replace(/\/[^\/]+$/, ""); + path = path.slice(0, -3); + } + const newPath = toAppend ? `${newPathname}${path}` : `${path}`; - <script> - function changeUrl(path, toAppend = false) { - const urlParams = new URLSearchParams(window.location.search); - newPathname = location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname; - while (path.slice(-3) == '/..' && toAppend) { - newPathname = newPathname.replace(/\/[^\/]+$/, ''); - path = path.slice(0, -3); + history.pushState( + {}, + "", + `${newPath}${urlParams.entries.length == 0 ? "" : "?" + urlParams.toString()}`, + ); + window.dispatchEvent(new Event("popstate")); + return false; } - 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> + function convertRemToPixels(rem) { + return ( + rem * + parseFloat( + getComputedStyle(document.documentElement).fontSize, + ) + ); + } + </script> - <script src="{{ .StaticStorageBaseUrl }}/libs/glightbox/3.3.0/js/glightbox.min.js"></script> + <script src="{{ .StaticStorageBaseUrl }}/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; + <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); + 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]; } - 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]); + 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; + function calculateVmin(percent) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - const smallerDimension = Math.min(viewportWidth, viewportHeight); + const smallerDimension = Math.min( + viewportWidth, + viewportHeight, + ); - const vminValue = (smallerDimension / 100) * percent; + const vminValue = (smallerDimension / 100) * percent; - return vminValue; - } + return vminValue; + } - function switchThemeDecor(theme) { - document.querySelectorAll('.theme-decor').forEach((e) => { - e.remove(); - }); + function switchThemeDecor(theme) { + document.querySelectorAll(".theme-decor").forEach((e) => { + e.remove(); + }); - if (theme == "" || typeof theme == "undefined") { - theme = document.body.getAttribute('data-theme'); - } + if (theme == "" || typeof theme == "undefined") { + theme = document.body.getAttribute("data-theme"); + } - vmin = calculateVmin(1); - var clWidth = document.documentElement.clientWidth; - var clHeight = document.documentElement.clientHeight; + 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 = `{{ .StaticStorageBaseUrl }}/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; + 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 = `{{ .StaticStorageBaseUrl }}/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; } - 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); } - 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; - case "lilac": break; + break; + case "lilac": + break; + } } - } - 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 = "ram"; break; - case "ram": theme = "lilac"; break; - case "lilac": theme = "olive"; break; + 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 = "ram"; + break; + case "ram": + theme = "lilac"; + break; + case "lilac": + theme = "olive"; + break; + } } + localStorage.setItem("theme", theme); + document.body.setAttribute("data-theme", theme); + switchThemeDecor(theme); } - localStorage.setItem('theme', theme); - document.body.setAttribute('data-theme', theme); - switchThemeDecor(theme); - } - const savedTheme = localStorage.getItem('theme') || 'lilac'; - switchTheme(savedTheme); - </script> + 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> + <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> + <script> + const clientTimeZone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + </script> - <div class="grow bg-split-left"></div> + <div class="grow bg-split-left"></div> - <div class="flex flex-col sm:flex-row"> - <div id="sidebar" class="bg-sidebar flex - flex-row sticky w-dvw h-fit xs:w-[90dvw] - sm:left-0 sm:flex-col sm:fixed sm:w-fit - z-20 shadow-[0_0_1rem_black]"> - <nav aria-label="Main navigation" class="text-[3rem] flex flex-row sm:flex-col gap-0 relative z-20"> - <a onclick="return changeUrl('/..', true);" href="../" id="sidebar-back-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false"> - <i class="fas fa-circle-left flex faw-18 h-18 content-center text-center text-sidebar"></i> - </a> - <a onclick="return changeUrl('/{{ .Lang }}');" href="/{{ .Lang }}" id="sidebar-home-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false"> - <i class="fas fa-home flex faw-18 h-18 content-center text-center text-sidebar"></i> - </a> - <a onclick="return changeUrl('/{{ .Lang }}/blog');" href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false"> - <i class="fas fa-search flex faw-18 h-18 content-center text-center text-sidebar"></i> - </a> - <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false"> - <i class="fas fa-map flex faw-18 h-18 content-center text-center text-sidebar"></i> - </a> - <div id="toolbar-theme-button" onclick="toggleThemeWheel();" class="themed-button themed-button-sidebar w-fit h-fit" draggable="false"> - <i id="toolbar-theme-button-content" class="fas fa-swatchbook flex faw-18 h-18 content-center text-center text-sidebar"></i> + <div class="flex flex-col sm:flex-row"> + <div + id="sidebar" + class="bg-sidebar flex flex-row sticky w-dvw h-fit xs:w-[90dvw] sm:left-0 sm:flex-col sm:fixed sm:w-fit z-20 shadow-[0_0_1rem_black]" + > + <nav + aria-label="Main navigation" + class="text-[3rem] flex flex-row sm:flex-col gap-0 relative z-20" + > + <a + onclick="return changeUrl('/..', true);" + href="../" + id="sidebar-back-button" + class="themed-button themed-button-sidebar w-fit h-fit hidden" + draggable="false" + > + <i + class="fas fa-circle-left flex faw-18 h-18 content-center text-center text-sidebar" + ></i> + </a> + <a + onclick="return changeUrl('/{{ .Lang }}');" + href="/{{ .Lang }}" + id="sidebar-home-button" + class="themed-button themed-button-sidebar w-fit h-fit hidden" + draggable="false" + > + <i + class="fas fa-home flex faw-18 h-18 content-center text-center text-sidebar" + ></i> + </a> + <a + onclick="return changeUrl('/{{ .Lang }}/blog');" + href="/{{ .Lang }}/blog" + id="sidebar-blog-button" + class="themed-button themed-button-sidebar w-fit h-fit hidden" + draggable="false" + > + <i + class="fas fa-search flex faw-18 h-18 content-center text-center text-sidebar" + ></i> + </a> + <a + href="/{{ .Lang }}/map" + id="sidebar-map-button" + class="themed-button themed-button-sidebar w-fit h-fit hidden" + draggable="false" + > + <i + class="fas fa-map flex faw-18 h-18 content-center text-center text-sidebar" + ></i> + </a> + <div + id="toolbar-theme-button" + onclick="toggleThemeWheel()" + class="themed-button themed-button-sidebar w-fit h-fit" + draggable="false" + > + <i + id="toolbar-theme-button-content" + class="fas fa-swatchbook flex faw-18 h-18 content-center text-center text-sidebar" + ></i> + </div> + <a + onclick="return changeUrl('/{{ .Lang }}/user');" + href="/{{ .Lang }}/user" + id="sidebar-user-button" + class="themed-button themed-button-sidebar w-fit h-fit hidden" + draggable="false" + > + <i + class="fas fa-user flex faw-18 h-18 content-center text-center text-sidebar" + ></i> + </a> + </nav> + <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> - <a onclick="return changeUrl('/{{ .Lang }}/user');" href="/{{ .Lang }}/user" id="sidebar-user-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false"> - <i class="fas fa-user flex faw-18 h-18 content-center text-center text-sidebar"></i> - </a> - </nav> - <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"); + <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.setAttribute('onclick', `return changeUrl('/${lang}', false);`); - sidebarMapButton.setAttribute('href', `/${lang}/map`); - sidebarBlogButton.setAttribute('href', `/${lang}/blog`); - sidebarBlogButton.setAttribute('onclick', `return changeUrl('/${lang}/blog', false);`); - sidebarUserButton.setAttribute('href', `/${lang}/user`); - sidebarUserButton.setAttribute('onclick', `return changeUrl('/${lang}/user', false);`); + 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.setAttribute( + "onclick", + `return changeUrl('/${lang}', false);`, + ); + sidebarMapButton.setAttribute( + "href", + `/${lang}/map`, + ); + sidebarBlogButton.setAttribute( + "href", + `/${lang}/blog`, + ); + sidebarBlogButton.setAttribute( + "onclick", + `return changeUrl('/${lang}/blog', false);`, + ); + sidebarUserButton.setAttribute( + "href", + `/${lang}/user`, + ); + sidebarUserButton.setAttribute( + "onclick", + `return changeUrl('/${lang}/user', false);`, + ); + } } - } - window.addEventListener("popstate", updateSidebar); - window.addEventListener("load", updateSidebar, {once: true}); - </script> - </div> + window.addEventListener("popstate", updateSidebar); + window.addEventListener("load", updateSidebar, { + once: true, + }); + </script> + </div> - <div class="bg-paper bg-background-dark @container/main flex z-10 w-dvw xs:w-[90dvw] sm:w-[85dvw] md:w-[80dvw] lg:w-[75dvw] xl:w-[70dvw] 2xl:w-[60dvw] 3xl:w-[50dvw] grow-0 h-[calc(100dvh-5rem)] sm:h-dvh shadow-[0_0_1rem_black]"> - <div class="bg-paper bg-background-dark border-r-2 border-dashed border-main-hard"></div> + <div + class="bg-paper bg-background-dark @container/main flex z-10 w-dvw xs:w-[90dvw] sm:w-[85dvw] md:w-[80dvw] lg:w-[75dvw] xl:w-[70dvw] 2xl:w-[60dvw] 3xl:w-[50dvw] grow-0 h-[calc(100dvh-5rem)] sm:h-dvh shadow-[0_0_1rem_black]" + > + <div + class="bg-paper bg-background-dark border-r-2 border-dashed border-main-hard" + ></div> - <div class="max-h-full max-w-full flex flex-col grow bg-paper bg-background-dark relative pt-4 ml-8 pr-4"> - <header 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"> - </header> - <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> + <div + class="max-h-full max-w-full flex flex-col grow bg-paper bg-background-dark relative pt-4 ml-8 pr-4" + > + <header + 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" + ></header> + <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> - <hr class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-4 border-dotted border-main-hard mb-2"> + <hr + class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-4 border-dotted border-main-hard mb-2" + /> - <main id="general-page-body" class="relative flex flex-col grow overflow-y-auto" - hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> - </main> - <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> + <main + id="general-page-body" + class="relative flex flex-col grow overflow-y-auto" + hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" + hx-trigger="load" + hx-target="this" + hx-swap="innerHTML" + ></main> + <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> - <footer 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"> - </footer> - <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> + <footer + 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" + ></footer> + <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-hard mb-2" + /> + </div> - <div class="pr-[0.5dvw] bg-paper bg-background-dark shadow-2xl border-l-2 border-dotted border-main-hard"></div> + <div + class="pr-[0.5dvw] bg-paper bg-background-dark shadow-2xl border-l-2 border-dotted border-main-hard" + ></div> + </div> </div> - </div> - <div class="grow bg-split-right"></div> + <div class="grow bg-split-right"></div> - <script src="{{ .StaticStorageBaseUrl }}/libs/htmx/2.0.6/htmx.min.js"></script> - <script src="{{ .StaticStorageBaseUrl }}/libs/leaflet/1.9.4/leaflet.js"></script> - <script src="{{ .StaticStorageBaseUrl }}/libs/imagesloaded/5.0.0/imagesloaded.pkgd.min.js"></script> - <script src="{{ .StaticStorageBaseUrl }}/libs/packery/3.0.0/dist/packery.pkgd.min.js"></script> + <script src="{{ .StaticStorageBaseUrl }}/libs/htmx/2.0.6/htmx.min.js"></script> + <script src="{{ .StaticStorageBaseUrl }}/libs/leaflet/1.9.4/leaflet.js"></script> + <script src="{{ .StaticStorageBaseUrl }}/libs/imagesloaded/5.0.0/imagesloaded.pkgd.min.js"></script> + <script src="{{ .StaticStorageBaseUrl }}/libs/packery/3.0.0/dist/packery.pkgd.min.js"></script> - <script> - // Enable fitWidth-like attribute for Packery. Source: https://codepen.io/desandro/pen/kVmLYz - var __resetLayout = Packery.prototype._resetLayout; - Packery.prototype._resetLayout = function() { - __resetLayout.call( this ); - var parentSize = getSize( this.element.parentNode ); - var colW = this.columnWidth + this.gutter; - this.fitWidth = Math.floor( ( parentSize.innerWidth + this.gutter ) / colW ) * colW; - this.packer.width = this.fitWidth; - this.packer.height = Number.POSITIVE_INFINITY; - this.packer.reset(); - }; + <script> + // Enable fitWidth-like attribute for Packery. Source: https://codepen.io/desandro/pen/kVmLYz + var __resetLayout = Packery.prototype._resetLayout; + Packery.prototype._resetLayout = function () { + __resetLayout.call(this); + var parentSize = getSize(this.element.parentNode); + var colW = this.columnWidth + this.gutter; + this.fitWidth = + Math.floor((parentSize.innerWidth + this.gutter) / colW) * + colW; + this.packer.width = this.fitWidth; + this.packer.height = Number.POSITIVE_INFINITY; + this.packer.reset(); + }; - Packery.prototype._getContainerSize = function() { - var emptyWidth = 0; - for ( var i=0, len = this.packer.spaces.length; i < len; i++ ) { - var space = this.packer.spaces[i]; - if ( space.y === 0 && space.height === Number.POSITIVE_INFINITY ) { - emptyWidth += space.width; + Packery.prototype._getContainerSize = function () { + var emptyWidth = 0; + for (var i = 0, len = this.packer.spaces.length; i < len; i++) { + var space = this.packer.spaces[i]; + if ( + space.y === 0 && + space.height === Number.POSITIVE_INFINITY + ) { + emptyWidth += space.width; + } } - } - return { - width: this.fitWidth - this.gutter - emptyWidth, - height: this.maxY - this.gutter + return { + width: this.fitWidth - this.gutter - emptyWidth, + height: this.maxY - this.gutter, + }; }; - }; - Packery.prototype.needsResizeLayout = function() { - return true; - }; - </script> + Packery.prototype.needsResizeLayout = function () { + return true; + }; + </script> - <div id="general-page-bottom-embeds" class="flex" - hx-get="/api/v1/general-page/bottom-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> - </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> + <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('toolbar-theme-button'); - const themeIcons = document.querySelectorAll('.theme-icon'); - const themeWheelRadius = convertRemToPixels(6); + <script> + const themeWheel = document.getElementById("theme-wheel"); + const themeButton = document.getElementById("toolbar-theme-button"); + const themeIcons = document.querySelectorAll(".theme-icon"); + const themeWheelRadius = convertRemToPixels(6); - themeIcons.forEach(icon => { - if (icon.getAttribute('data-theme') == savedTheme) { - icon.classList.add('active'); - } - }); + themeIcons.forEach((icon) => { + if (icon.getAttribute("data-theme") == savedTheme) { + icon.classList.add("active"); + } + }); - function calculateVmax(percent) { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; + function calculateVmax(percent) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - const largerDimension = Math.max(viewportWidth, viewportHeight); + const largerDimension = Math.max(viewportWidth, viewportHeight); - const vmaxValue = (largerDimension / 100) * percent; + const vmaxValue = (largerDimension / 100) * percent; - return vmaxValue; - } + return vmaxValue; + } - function positionThemeIcons() { - const sliceAngle = 360 / themeIcons.length; + function positionThemeIcons() { + const sliceAngle = 360 / themeIcons.length; - themeIcons.forEach((icon, index) => { - const startAngle = index * sliceAngle; - const endAngle = (index + 1) * sliceAngle; + themeIcons.forEach((icon, index) => { + const startAngle = index * sliceAngle; + const endAngle = (index + 1) * sliceAngle; - const clipPath = createSliceClipPath(startAngle, endAngle, themeWheelRadius); - icon.style.clipPath = clipPath; - }); - } + 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`; - function createSliceClipPath(startAngle, endAngle, radius) { - const centerX = radius; - const centerY = radius; + const segments = Math.max( + 2, + Math.ceil(Math.abs(endAngle - startAngle) / 5), + ); + const angleStep = (endAngle - startAngle) / segments; - let points = `${centerX}px ${centerY}px`; + 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`; + } - const segments = Math.max(2, Math.ceil(Math.abs(endAngle - startAngle) / 5)); - const angleStep = (endAngle - startAngle) / segments; + points += `, ${centerX}px ${centerY}px`; - 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`; + return `polygon(${points})`; } - 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; - function positionThemeWheel() { - const buttonRect = themeButton.getBoundingClientRect(); - const buttonCenterX = buttonRect.left + buttonRect.width / 2; - const buttonCenterY = buttonRect.top + buttonRect.height / 2; + const margin = 0; - const margin = 0; + let wheelX = buttonCenterX - themeWheelRadius; + let wheelY = buttonCenterY - themeWheelRadius; - let wheelX = buttonCenterX - themeWheelRadius; - let wheelY = buttonCenterY - themeWheelRadius; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; + if (wheelX < margin) { + wheelX = margin; + } else if ( + wheelX + themeWheelRadius * 2 > + viewportWidth - margin + ) { + wheelX = viewportWidth - themeWheelRadius * 2 - margin; + } - if (wheelX < margin) { - wheelX = margin; - } else if (wheelX + (themeWheelRadius * 2) > viewportWidth - margin) { - wheelX = viewportWidth - (themeWheelRadius * 2) - margin; - } + if (wheelY < margin) { + wheelY = margin; + } else if ( + wheelY + themeWheelRadius * 2 > + viewportHeight - margin + ) { + wheelY = viewportHeight - themeWheelRadius * 2 - margin; + } - if (wheelY < margin) { - wheelY = margin; - } else if (wheelY + (themeWheelRadius * 2) > viewportHeight - margin) { - wheelY = viewportHeight - (themeWheelRadius * 2) - margin; + themeWheel.style.left = wheelX + "px"; + themeWheel.style.top = wheelY + "px"; } - themeWheel.style.left = wheelX + 'px'; - themeWheel.style.top = wheelY + 'px'; - } - - function hideThemeWheel() { - themeWheel.classList.remove('active'); - } + function hideThemeWheel() { + themeWheel.classList.remove("active"); + } - function showThemeWheel() { - positionThemeWheel(); - positionThemeIcons(); - themeWheel.classList.add('active'); - } + function showThemeWheel() { + positionThemeWheel(); + positionThemeIcons(); + themeWheel.classList.add("active"); + } - function toggleThemeWheel() { - if (themeWheel.classList.contains('active')) { - hideThemeWheel(); - } else { - showThemeWheel(); + function toggleThemeWheel() { + if (themeWheel.classList.contains("active")) { + hideThemeWheel(); + } else { + showThemeWheel(); + } } - } - themeIcons.forEach(icon => { - icon.addEventListener('click', (e) => { - e.stopPropagation(); - const theme = icon.getAttribute('data-theme'); - switchTheme(theme); - themeIcons.forEach(icon => { - icon.classList.remove('active'); - }) - icon.classList.add('active'); + themeIcons.forEach((icon) => { + icon.addEventListener("click", (e) => { + e.stopPropagation(); + const theme = icon.getAttribute("data-theme"); + switchTheme(theme); + themeIcons.forEach((icon) => { + icon.classList.remove("active"); + }); + icon.classList.add("active"); + }); }); - }); - - themeWheel.addEventListener('mouseleave', hideThemeWheel); - document.addEventListener('click', (e) => { - if (!themeWheel.contains(e.target) && e.target !== themeButton && !e.target.classList.contains("toolbar-theme-button-content")) { - hideThemeWheel(); - } - }); - </script> + themeWheel.addEventListener("mouseleave", hideThemeWheel); - <script> - let decorResizeTimeout; - window.addEventListener('resize', function() { - clearTimeout(decorResizeTimeout); - decorResizeTimeout = setTimeout(switchThemeDecor, 300); - }); - </script> + document.addEventListener("click", (e) => { + if ( + !themeWheel.contains(e.target) && + e.target !== themeButton && + !e.target.classList.contains("toolbar-theme-button-content") + ) { + hideThemeWheel(); + } + }); + </script> -</body> + <script> + let decorResizeTimeout; + window.addEventListener("resize", function () { + clearTimeout(decorResizeTimeout); + decorResizeTimeout = setTimeout(switchThemeDecor, 300); + }); + </script> + </body> </html> diff --git a/views/pages/global-map.html b/views/pages/global-map.html index aa6ddfd..ce9d5b1 100644 --- a/views/pages/global-map.html +++ b/views/pages/global-map.html @@ -18,6 +18,10 @@ <link rel="canonical" href="{{ .CanonicalEndpoint }}/{{ .Lang }}/map" /> <link rel="stylesheet" + href="{{ .StaticStorageBaseUrl }}/fonts/fonts.css" + /> + <link + rel="stylesheet" href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/fontawesome.min.css" /> <link @@ -29,7 +33,19 @@ href="{{ .StaticStorageBaseUrl }}/libs/font-awesome/7.2.0/css/solid.min.css" /> {{ block "header" . }}{{ end }} - <link href="/output.css" rel="stylesheet" /> + <link rel="stylesheet" href="/output.css" /> + <link + rel="icon" + href="/favicon-light.svg" + type="image/svg+xml" + media="(prefers-color-scheme: light)" + /> + <link + rel="icon" + href="/favicon-dark.svg" + type="image/svg+xml" + media="(prefers-color-scheme: dark)" + /> <link rel="stylesheet" href="{{ .StaticStorageBaseUrl }}/libs/leaflet/1.9.4/leaflet.css" diff --git a/views/pages/robots.txt b/views/pages/robots.txt index 1e5bebb..aaef3f4 100644 --- a/views/pages/robots.txt +++ b/views/pages/robots.txt @@ -1,5 +1,7 @@ User-agent: * Allow: / -Disallow: /api/ +Disallow: /api/v1/email/ +Disallow: /ru/user/ +Disallow: /en/user/ Sitemap: {{ .CanonicalEndpoint }}/sitemap.xml
\ No newline at end of file diff --git a/views/pages/unsubscribe-page.html b/views/pages/unsubscribe-page.html index c9de473..a917746 100644 --- a/views/pages/unsubscribe-page.html +++ b/views/pages/unsubscribe-page.html @@ -4,6 +4,18 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="robots" content="noindex,nofollow" /> + <link + rel="icon" + href="/favicon-light.svg" + type="image/svg+xml" + media="(prefers-color-scheme: light)" + /> + <link + rel="icon" + href="/favicon-dark.svg" + type="image/svg+xml" + media="(prefers-color-scheme: dark)" + /> <title>{{ .L.UnsubscribePage.Header }} // SAYA.UZ</title> <style> .bg-paper { |