summaryrefslogtreecommitdiffci
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build-and-deploy-prod.yml102
-rw-r--r--.github/workflows/build-and-deploy-stage.yml104
-rw-r--r--Dockerfile13
-rw-r--r--Jenkinsfile313
-rw-r--r--config/config.go18
-rw-r--r--config/config.local.yaml27
-rw-r--r--config/config.prod.yaml35
-rw-r--r--config/config.stage.yaml27
-rw-r--r--deploy/playbook.yml9
-rw-r--r--go.mod14
-rw-r--r--go.sum28
-rw-r--r--internal/blog/b2.go31
-rw-r--r--internal/blog/client.go1
-rw-r--r--internal/blog/index.go83
-rw-r--r--internal/blog/s3.go269
-rw-r--r--internal/glightbox/html_renderer.go4
-rw-r--r--internal/mailer/mailer.go16
-rw-r--r--internal/router/handlers/api-v1-blog-search.go10
-rw-r--r--internal/router/handlers/api-v1-email-is-in-verification.go3
-rw-r--r--internal/router/handlers/api-v1-email-send-verification-code.go15
-rw-r--r--internal/router/handlers/api-v1-email-verify.go7
-rw-r--r--internal/router/handlers/api-v1-like-get.go2
-rw-r--r--internal/router/handlers/api-v1-map-get.go116
-rw-r--r--internal/router/handlers/api-v1-subs-put.go7
-rw-r--r--internal/router/handlers/lang-blog-title.go29
-rw-r--r--internal/router/handlers/lang-blog.go16
-rw-r--r--internal/router/handlers/lang-map.go65
-rw-r--r--internal/router/handlers/lang-user-unsubscribe.go9
-rw-r--r--internal/router/handlers/lang-user.go9
-rw-r--r--internal/router/handlers/lang.go9
-rw-r--r--internal/router/handlers/root.go7
-rw-r--r--internal/router/router.go61
-rw-r--r--internal/tailwind/transformer.go2
-rw-r--r--internal/templatemanager/templatemanager.go6
-rw-r--r--l10n/en.yaml (renamed from locale/localization.en.yaml)0
-rw-r--r--l10n/ru.yaml (renamed from locale/localization.ru.yaml)0
-rw-r--r--l10n/translator-hook.go39
-rw-r--r--l10n/translator.go167
-rw-r--r--locale/localization.go144
-rw-r--r--static/input.css373
-rw-r--r--views/layouts/general-page.html100
-rw-r--r--views/messages/new-post.html6
-rw-r--r--views/messages/verify-email.html10
-rw-r--r--views/pages/blog-catalogue.html57
-rw-r--r--views/pages/blog-page.html75
-rw-r--r--views/pages/global-map.html127
-rw-r--r--views/pages/home-page.html45
-rw-r--r--views/pages/language-pick.html4
-rw-r--r--views/pages/unsubscribe-page.html2
-rw-r--r--views/pages/user-page.html46
-rw-r--r--views/partials/blog-page-like-button.html6
-rw-r--r--views/partials/catalogue-blog-cards.html12
-rw-r--r--views/partials/general-page-body.html1
-rw-r--r--views/partials/general-page-header.html4
-rw-r--r--views/partials/global-map-widget.html109
-rw-r--r--views/partials/personal-page-status.html2
56 files changed, 1607 insertions, 1189 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml
deleted file mode 100644
index 060ae32..0000000
--- a/.github/workflows/build-and-deploy-prod.yml
+++ /dev/null
@@ -1,102 +0,0 @@
-name: Docker image building and publishing onto Production
-on:
- push:
- tags:
- - "*"
-jobs:
- build-and-push:
- if: github.repository == 'SayaAndy/saya-today-web'
- runs-on: ubuntu-latest
- environment: Production
- permissions:
- packages: write
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Generate output.css with tailwindcss
- uses: ZoeyVid/tailwindcss-update@main
- with:
- input: static/input.css
- output: static/output.css
- params: "--minify"
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v4
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v4
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GHCR_TOKEN }}
-
- - name: Build and push
- uses: docker/build-push-action@v7
- with:
- context: .
- file: ./Dockerfile
- push: true
- cache-from: type=gha
- cache-to: type=gha,mode=max
- tags: |
- ghcr.io/sayaandy/saya-today-web:latest
- ghcr.io/sayaandy/saya-today-web:stable
- ghcr.io/sayaandy/saya-today-web:${{ github.ref_name }}
-
- deploy:
- if: github.repository == 'SayaAndy/saya-today-web'
- runs-on: ubuntu-latest
- environment: Production
- needs: [build-and-push]
- container:
- image: ghcr.io/ansible/community-ansible-dev-tools:v26.4.6
- options: --user root
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Install community.docker collection
- run: ansible-galaxy collection install community.docker
-
- - name: Set up SSH connection for uz.saya.casa
- run: |
- mkdir -p ~/.ssh
- (cat <<EOF
- ${{ secrets.SVC_GITHUB_PK }}
- EOF
- ) > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- sed -i 's/\r$//' ~/.ssh/id_ed25519
- ssh-keyscan -H uz.saya.casa >> ~/.ssh/known_hosts
-
- - name: Test SSH connection
- run: |
- ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 svc_github@uz.saya.casa "echo 'SSH connection successful'"
-
- - name: Run Ansible playbook
- env:
- ANSIBLE_HOST_KEY_CHECKING: "False"
- working-directory: ./deploy
- run: |
- ansible-playbook \
- playbook.yml \
- -i inventory.yml \
- -l prod \
- --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
deleted file mode 100644
index ce4a1fb..0000000
--- a/.github/workflows/build-and-deploy-stage.yml
+++ /dev/null
@@ -1,104 +0,0 @@
-name: Docker image building and publishing onto Stage
-on:
- push:
- branches: [stage]
-jobs:
- build-and-push:
- if: github.repository == 'SayaAndy/saya-today-web'
- runs-on: ubuntu-latest
- environment: Stage
- permissions:
- packages: write
- outputs:
- sha_short: ${{ steps.ghss_vars.outputs.sha_short }}
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Generate output.css with tailwindcss
- uses: ZoeyVid/tailwindcss-update@main
- with:
- input: static/input.css
- output: static/output.css
- params: "--minify"
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v4
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v4
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GHCR_TOKEN }}
-
- - name: Set github short sha
- id: ghss_vars
- run: echo "sha_short=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
-
- - name: Build and push
- uses: docker/build-push-action@v7
- with:
- context: .
- file: ./Dockerfile
- push: true
- cache-from: type=gha
- cache-to: type=gha,mode=max
- tags: |
- ghcr.io/sayaandy/saya-today-web:latest
- ghcr.io/sayaandy/saya-today-web:commit-${{ steps.ghss_vars.outputs.sha_short }}
-
- deploy:
- if: github.repository == 'SayaAndy/saya-today-web'
- runs-on: ubuntu-latest
- environment: Stage
- needs: [build-and-push]
- container:
- image: ghcr.io/ansible/community-ansible-dev-tools:v26.4.6
- options: --user root
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Install community.docker collection
- run: ansible-galaxy collection install community.docker
-
- - name: Set up SSH connection for uz.saya.casa
- run: |
- mkdir -p ~/.ssh
- (cat <<EOF
- ${{ secrets.SVC_GITHUB_PK }}
- EOF
- ) > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- sed -i 's/\r$//' ~/.ssh/id_ed25519
- ssh-keyscan -H uz.saya.casa >> ~/.ssh/known_hosts
-
- - name: Test SSH connection
- run: |
- ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 svc_github@uz.saya.casa "echo 'SSH connection successful'"
-
- - name: Run Ansible playbook
- env:
- ANSIBLE_HOST_KEY_CHECKING: "False"
- working-directory: ./deploy
- run: |
- ansible-playbook \
- playbook.yml \
- -i inventory.yml \
- -l stage \
- --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-${{ needs.build-and-push.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/Dockerfile b/Dockerfile
index 318ca51..d0b6621 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,9 @@
-FROM golang:1.26-alpine3.23 AS build-stage
+FROM golang:1.26.4-alpine3.24 AS build-stage
+
+ARG TARGETOS=linux
+ARG TARGETARCH=amd64
+ENV GOOS=${TARGETOS}
+ENV GOARCH=${TARGETARCH}
RUN apk add --no-cache sqlite-dev musl-dev gcc
@@ -7,7 +12,7 @@ COPY . .
RUN go mod download
RUN CGO_ENABLED=1 go build -o sayana-web .
-FROM alpine:3.23 AS runtime-stage
+FROM alpine:3.24 AS runtime-stage
ENV ENVIRONMENT=""
ENV AUTH_SALT=""
@@ -19,8 +24,8 @@ COPY --from=build-stage /builddir/sayana-web /app/sayana-web
COPY --from=build-stage /builddir/migrations /app/migrations
COPY --from=build-stage /builddir/static /app/static
COPY --from=build-stage /builddir/views /app/views
-COPY --from=build-stage /builddir/locale/*.yaml /app/locale/
-COPY --from=build-stage /builddir/config/config*.yaml /app/config/
+COPY --from=build-stage /builddir/l10n/*.yaml /app/l10n/
+COPY --from=build-stage /builddir/config/config.*.yaml /app/config/
RUN apk add --no-cache tzdata sqlite
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 0000000..8633145
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,313 @@
+pipeline {
+ agent none
+
+ stages {
+ stage('Compile Tailwind CSS') {
+ agent {
+ docker {
+ image 'node:26.3.1-alpine3.24'
+ args '-v /tmp:/tmp -v /.npm:/.npm'
+ }
+ }
+ steps {
+ checkout scm
+ sh '''
+ npm install tailwindcss @tailwindcss/cli @tailwindcss/forms
+ npx tailwindcss -i static/input.css -o static/output.css --minify
+ '''
+ stash name: 'tailwindcss-output', includes: 'static/output.css'
+ }
+ }
+
+ stage('Matrix Build') {
+ matrix {
+ axes {
+ axis {
+ name 'GOOS'
+ values 'linux'
+ }
+ axis {
+ name 'GOARCH'
+ values 'arm64', 'amd64'
+ }
+ }
+ stages {
+ stage('Build') {
+ agent {
+ kubernetes {
+ defaultContainer 'kaniko'
+ yaml """
+apiVersion: v1
+kind: Pod
+metadata:
+ namespace: jenkins
+spec:
+ nodeSelector:
+ kubernetes.io/arch: ${GOARCH}
+ containers:
+ - name: kaniko
+ image: gcr.io/kaniko-project/executor:v1.24.0-debug
+ imagePullPolicy: Always
+ command: [ /busybox/cat ]
+ tty: true
+ resources:
+ requests:
+ cpu: "1"
+ memory: 2Gi
+ ephemeral-storage: 4Gi
+ limits:
+ memory: 4Gi
+ ephemeral-storage: 8Gi
+"""
+ }
+ }
+ environment {
+ IMAGE_PUSH_DESTINATION="registry.sayag.it/sayauz/web"
+ }
+ steps {
+ checkout scm
+ unstash 'tailwindcss-output'
+ container(name: 'kaniko', shell: '/busybox/sh') {
+ withCredentials([string(credentialsId: 'registry-sayagit-jenkins-password', variable: 'REGISTRY_SAYAGIT_JENKINS_PASSWORD')]) {
+ withEnv(['PATH+EXTRA=/busybox']) {
+ script {
+ env.GIT_COMMIT_SHORT = env.GIT_COMMIT ? env.GIT_COMMIT.take(7) : "unknown"
+ try {
+ sh '''#!/busybox/sh
+ mkdir -p /kaniko/.docker
+ printf '{"auths":{"registry.sayag.it":{"username":"jenkins","password":"%s"}}}' "$REGISTRY_SAYAGIT_JENKINS_PASSWORD" > /kaniko/.docker/config.json
+ /kaniko/executor --context `pwd` \
+ --custom-platform=linux/${GOARCH} \
+ --build-arg TARGETOS=linux \
+ --build-arg TARGETARCH=${GOARCH} \
+ --destination $IMAGE_PUSH_DESTINATION:commit-$GIT_COMMIT_SHORT-$GOARCH
+ '''
+ } catch (Exception e) {
+ echo "Caught exception: ${e.getMessage()}"
+ currentBuild.result = 'FAILURE'
+ throw e
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ stage('Stitch Manifest') {
+ agent {
+ kubernetes {
+ defaultContainer 'manifest-tool'
+ yaml """
+apiVersion: v1
+kind: Pod
+metadata:
+ namespace: jenkins
+spec:
+ containers:
+ - name: manifest-tool
+ image: mplatform/manifest-tool:alpine-v2.2.2
+ imagePullPolicy: Always
+ command: [ 'cat' ]
+ tty: true
+ resources:
+ requests:
+ cpu: "1"
+ memory: 2Gi
+ ephemeral-storage: 4Gi
+"""
+ }
+ }
+ environment {
+ IMAGE_PUSH_DESTINATION="registry.sayag.it/sayauz/web"
+ }
+ steps {
+ script {
+ env.GIT_COMMIT_SHORT = env.GIT_COMMIT ? env.GIT_COMMIT.take(7) : "unknown"
+ }
+ withCredentials([string(credentialsId: 'registry-sayagit-jenkins-password', variable: 'REGISTRY_SAYAGIT_JENKINS_PASSWORD')]) {
+ sh '''mkdir -p /root/.docker
+ printf '{"auths":{"registry.sayag.it":{"username":"jenkins","password":"%s"}}}' "$REGISTRY_SAYAGIT_JENKINS_PASSWORD" > /root/.docker/config.json
+
+ manifest-tool push from-args \
+ --platforms linux/amd64,linux/arm64 \
+ --template $IMAGE_PUSH_DESTINATION:commit-$GIT_COMMIT_SHORT-ARCH \
+ --target $IMAGE_PUSH_DESTINATION:commit-$GIT_COMMIT_SHORT
+ manifest-tool push from-args \
+ --platforms linux/amd64,linux/arm64 \
+ --template $IMAGE_PUSH_DESTINATION:commit-$GIT_COMMIT_SHORT-ARCH \
+ --target $IMAGE_PUSH_DESTINATION:latest
+ if [ "$TAG_NAME" != "" ]; then
+ manifest-tool push from-args \
+ --platforms linux/amd64,linux/arm64 \
+ --template $IMAGE_PUSH_DESTINATION:commit-$GIT_COMMIT_SHORT-ARCH \
+ --target $IMAGE_PUSH_DESTINATION:stable
+ manifest-tool push from-args \
+ --platforms linux/amd64,linux/arm64 \
+ --template $IMAGE_PUSH_DESTINATION:commit-$GIT_COMMIT_SHORT-ARCH \
+ --target $IMAGE_PUSH_DESTINATION:$TAG_NAME
+ fi
+ '''
+ }
+ }
+ }
+ stage('Deploy (Stage)') {
+ when { branch 'stage' }
+ agent {
+ kubernetes {
+ defaultContainer 'ansible'
+ yaml """
+apiVersion: v1
+kind: Pod
+metadata:
+ namespace: jenkins
+spec:
+ containers:
+ - name: ansible
+ image: ghcr.io/ansible/community-ansible-dev-tools:v26.7.2
+ command: [ 'cat' ]
+ tty: true
+ resources:
+ requests:
+ cpu: "500m"
+ memory: 1Gi
+"""
+ }
+ }
+ steps {
+ checkout scm
+ container('ansible') {
+ script {
+ env.GIT_COMMIT_SHORT = env.GIT_COMMIT ? env.GIT_COMMIT.take(7) : "unknown"
+
+ def secretVars = [
+ 'sayauz-web-stage-auth-salt' : 'AUTH_SALT',
+ 'sayauz-web-stage-s3-access-key-id' : 'S3_ACCESS_KEY_ID',
+ 'sayauz-web-stage-s3-secret-access-key' : 'S3_SECRET_ACCESS_KEY',
+ 'sayauz-web-stage-mail-salt' : 'MAIL_SALT',
+ 'sayauz-web-stage-mail-host' : 'MAIL_HOST',
+ 'sayauz-web-stage-mail-address' : 'MAIL_ADDRESS',
+ 'sayauz-web-stage-mail-username' : 'MAIL_USERNAME',
+ 'sayauz-web-stage-mail-password' : 'MAIL_PASSWORD'
+ ]
+ def bindings = secretVars.collect { id, varName -> string(credentialsId: id, variable: varName) }
+ bindings << sshUserPrivateKey(credentialsId: 'sayauz-svc-github', keyFileVariable: 'SSH_KEY_FILE', usernameVariable: 'SSH_USER')
+
+ withCredentials(bindings) {
+ sh '''
+ ansible-galaxy collection install community.docker
+
+ mkdir -p ~/.ssh
+ cp "$SSH_KEY_FILE" ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+ ssh-keyscan -H uz.saya.casa >> ~/.ssh/known_hosts
+
+ ssh -o ConnectTimeout=10 -i ~/.ssh/id_ed25519 "$SSH_USER@uz.saya.casa" "echo 'SSH connection successful'"
+
+ cd deploy
+ ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook \
+ playbook.yml \
+ -i inventory.yml \
+ -l stage \
+ --private-key ~/.ssh/id_ed25519 \
+ -u "$SSH_USER" \
+ -e saya_today_web_auth_salt="$AUTH_SALT" \
+ -e saya_today_web_s3_access_key_id="$S3_ACCESS_KEY_ID" \
+ -e saya_today_web_s3_secret_access_key="$S3_SECRET_ACCESS_KEY" \
+ -e saya_today_web_environment=stage \
+ -e saya_today_web_tag=commit-$GIT_COMMIT_SHORT \
+ -e saya_today_web_mail_salt="$MAIL_SALT" \
+ -e saya_today_web_mail_host="$MAIL_HOST" \
+ -e saya_today_web_mail_address="$MAIL_ADDRESS" \
+ -e saya_today_web_mail_username="$MAIL_USERNAME" \
+ -e saya_today_web_mail_password="$MAIL_PASSWORD"
+ '''
+ }
+ }
+ }
+ }
+ }
+ stage('Deploy (Prod)') {
+ when { buildingTag() }
+ agent {
+ kubernetes {
+ defaultContainer 'ansible'
+ yaml """
+apiVersion: v1
+kind: Pod
+metadata:
+ namespace: jenkins
+spec:
+ containers:
+ - name: ansible
+ image: ghcr.io/ansible/community-ansible-dev-tools:v26.7.2
+ command: [ 'cat' ]
+ tty: true
+ resources:
+ requests:
+ cpu: "500m"
+ memory: 1Gi
+"""
+ }
+ }
+ steps {
+ checkout scm
+ container('ansible') {
+ script {
+ def secretVars = [
+ 'sayauz-web-prod-auth-salt' : 'AUTH_SALT',
+ 'sayauz-web-prod-s3-access-key-id' : 'S3_ACCESS_KEY_ID',
+ 'sayauz-web-prod-s3-secret-access-key' : 'S3_SECRET_ACCESS_KEY',
+ 'sayauz-web-prod-mail-salt' : 'MAIL_SALT',
+ 'sayauz-web-prod-mail-host' : 'MAIL_HOST',
+ 'sayauz-web-prod-mail-address' : 'MAIL_ADDRESS',
+ 'sayauz-web-prod-mail-username' : 'MAIL_USERNAME',
+ 'sayauz-web-prod-mail-password' : 'MAIL_PASSWORD',
+ 'sayauz-web-prod-verification-google' : 'GOOGLE_VERIFICATION',
+ 'sayauz-web-prod-verification-yandex' : 'YANDEX_VERIFICATION',
+ 'sayauz-web-prod-verification-bing' : 'BING_VERIFICATION'
+ ]
+ def bindings = secretVars.collect { id, varName -> string(credentialsId: id, variable: varName) }
+ bindings << sshUserPrivateKey(credentialsId: 'sayauz-svc-github', keyFileVariable: 'SSH_KEY_FILE', usernameVariable: 'SSH_USER')
+
+ withCredentials(bindings) {
+ sh '''
+ ansible-galaxy collection install community.docker
+
+ mkdir -p ~/.ssh
+ cp "$SSH_KEY_FILE" ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+ ssh-keyscan -H uz.saya.casa >> ~/.ssh/known_hosts
+
+ ssh -o ConnectTimeout=10 -i ~/.ssh/id_ed25519 "$SSH_USER@uz.saya.casa" "echo 'SSH connection successful'"
+
+ cd deploy
+ ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook \
+ playbook.yml \
+ -i inventory.yml \
+ -l prod \
+ --private-key ~/.ssh/id_ed25519 \
+ -u "$SSH_USER" \
+ -e saya_today_web_auth_salt="$AUTH_SALT" \
+ -e saya_today_web_s3_access_key_id="$S3_ACCESS_KEY_ID" \
+ -e saya_today_web_s3_secret_access_key="$S3_SECRET_ACCESS_KEY" \
+ -e saya_today_web_environment=prod \
+ -e saya_today_web_tag=$TAG_NAME \
+ -e saya_today_web_mail_salt="$MAIL_SALT" \
+ -e saya_today_web_mail_host="$MAIL_HOST" \
+ -e saya_today_web_mail_address="$MAIL_ADDRESS" \
+ -e saya_today_web_mail_username="$MAIL_USERNAME" \
+ -e saya_today_web_mail_password="$MAIL_PASSWORD" \
+ -e saya_today_google_verification="$GOOGLE_VERIFICATION" \
+ -e saya_today_yandex_verification="$YANDEX_VERIFICATION" \
+ -e saya_today_bing_verification="$BING_VERIFICATION"
+ '''
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/config/config.go b/config/config.go
index 0b1570a..9185e6a 100644
--- a/config/config.go
+++ b/config/config.go
@@ -20,9 +20,9 @@ type Config struct {
Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"`
Mail MailConfig `json:"Mail" yaml:"mail" validate:"required"`
CanonicalEndpoint string `json:"CanonicalEndpoint" yaml:"canonicalEndpoint" validate:"required"`
- Meta MetaConfig `json:"Meta" yaml:"meta"`
+ Meta []MetaConfig `json:"Meta" yaml:"meta"`
PhotoStorage PhotoStorageConfig `json:"PhotoStorage" yaml:"photoStorage"`
- StaticStorage PhotoTypeConfig `json:"StaticStorage" yaml:"staticStorage"`
+ StaticStorage StaticStorageConfig `json:"StaticStorage" yaml:"staticStorage" validate:"required"`
AllowOrigins []string `json:"AllowOrigins" yaml:"allowOrigins"`
}
@@ -237,8 +237,8 @@ type TriggerConfig struct {
}
type MetaConfig struct {
- GoogleSiteVerification string `json:"GoogleSiteVerification" yaml:"googleSiteVerification"`
- YandexVerification string `json:"YandexVerification" yaml:"yandexVerification"`
+ Name string `json:"Name" yaml:"name"`
+ Value string `json:"Value" yaml:"value"`
}
type PhotoStorageConfig struct {
@@ -261,6 +261,16 @@ type HomePageGifsConfig struct {
Indexes []string `json:"Indexes" yaml:"indexes"`
}
+type StaticStorageConfig struct {
+ BaseUrl string `json:"BaseUrl" yaml:"baseUrl" validate:"url,required"`
+ Map MapStorageConfig `json:"Map" yaml:"map" validate:"required"`
+}
+
+type MapStorageConfig struct {
+ BaseUrl string `json:"BaseUrl" yaml:"baseUrl" validate:"url,required"`
+ PMTiles string `json:"PMTiles" yaml:"pmTiles" validate:"required"`
+}
+
func LoadConfig(path string, config *Config) error {
fileBytes, err := os.ReadFile(path)
if err != nil {
diff --git a/config/config.local.yaml b/config/config.local.yaml
index d3e2e33..fefe2e7 100644
--- a/config/config.local.yaml
+++ b/config/config.local.yaml
@@ -30,11 +30,11 @@ localePath: ./locale/
availableLanguages:
- name: ru
alt: Русский
- flag: https://storage.yandexcloud.kz/sayauz-static/flags/ru.svg
+ flag: https://cdn.saya.uz/stats/flags/ru.svg
locFile: localization.ru.yaml
- name: en
alt: English
- flag: https://storage.yandexcloud.kz/sayauz-static/flags/gb.svg
+ flag: https://cdn.saya.uz/stats/flags/gb.svg
locFile: localization.en.yaml
auth:
db:
@@ -56,23 +56,26 @@ mail:
canonicalEndpoint: "http://127.0.0.1:3000"
photoStorage:
full:
- baseUrl: https://storage.yandexcloud.kz/sayauz-photos/%s
+ baseUrl: https://cdn.saya.uz/photos/jpeg-full/%s
webp:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-full/%s.webp
thumbnail1600p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp1600/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-1600p/%s.webp
thumbnail1200p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp1200/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-1200p/%s.webp
thumbnail800p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp800/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-800p/%s.webp
thumbnail560p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp560/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-560p/%s.webp
thumbnail320p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp320/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-320p/%s.webp
homePageGifs:
- baseUrl: https://storage.yandexcloud.kz/sayauz-static/home-page-gifs/otter-%s.gif
+ baseUrl: https://cdn.saya.uz/stats/home-page-gifs/otter-%s.gif
indexes: ["1", "2", "3"]
staticStorage:
- baseUrl: https://storage.yandexcloud.kz/sayauz-static
+ baseUrl: https://cdn.saya.uz/stats
+ map:
+ baseUrl: https://cdn.saya.uz/map
+ pmTiles: global.pmtiles
allowOrigins:
- - https://storage.yandexcloud.kz
+ - https://cdn.saya.uz
diff --git a/config/config.prod.yaml b/config/config.prod.yaml
index e5785f8..6ec00d8 100644
--- a/config/config.prod.yaml
+++ b/config/config.prod.yaml
@@ -31,11 +31,11 @@ localePath: ./locale/
availableLanguages:
- name: ru
alt: Русский
- flag: https://storage.yandexcloud.kz/sayauz-static/flags/ru.svg
+ flag: https://cdn.saya.uz/stats/flags/ru.svg
locFile: localization.ru.yaml
- name: en
alt: English
- flag: https://storage.yandexcloud.kz/sayauz-static/flags/gb.svg
+ flag: https://cdn.saya.uz/stats/flags/gb.svg
locFile: localization.en.yaml
auth:
db:
@@ -55,27 +55,34 @@ mail:
onNewPost: "0/5 * * * *"
canonicalEndpoint: "https://${FQDN}"
meta:
- googleSiteVerification: "${GOOGLE_SITE_VERIFICATION}"
- yandexVerification: "${YANDEX_VERIFICATION}"
+ - name: google-site-verification
+ value: "${GOOGLE_SITE_VERIFICATION}"
+ - name: yandex-verification
+ value: "${YANDEX_VERIFICATION}"
+ - name: msvalidate.01
+ value: "${BING_VERIFICATION}"
photoStorage:
full:
- baseUrl: https://storage.yandexcloud.kz/sayauz-photos/%s
+ baseUrl: https://cdn.saya.uz/photos/jpeg-full/%s
webp:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-full/%s.webp
thumbnail1600p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp1600/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-1600p/%s.webp
thumbnail1200p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp1200/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-1200p/%s.webp
thumbnail800p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp800/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-800p/%s.webp
thumbnail560p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp560/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-560p/%s.webp
thumbnail320p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp320/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-320p/%s.webp
homePageGifs:
- baseUrl: https://storage.yandexcloud.kz/sayauz-static/home-page-gifs/otter-%s.gif
+ baseUrl: https://cdn.saya.uz/stats/home-page-gifs/otter-%s.gif
indexes: ["1", "2", "3"]
staticStorage:
- baseUrl: https://storage.yandexcloud.kz/sayauz-static
+ baseUrl: https://cdn.saya.uz/stats
+ map:
+ baseUrl: https://cdn.saya.uz/map
+ pmTiles: global.pmtiles
allowOrigins:
- - https://storage.yandexcloud.kz
+ - https://cdn.saya.uz
diff --git a/config/config.stage.yaml b/config/config.stage.yaml
index c2e337b..d8819ca 100644
--- a/config/config.stage.yaml
+++ b/config/config.stage.yaml
@@ -31,11 +31,11 @@ localePath: ./locale/
availableLanguages:
- name: ru
alt: Русский
- flag: https://storage.yandexcloud.kz/sayauz-static/flags/ru.svg
+ flag: https://cdn.saya.uz/stats/flags/ru.svg
locFile: localization.ru.yaml
- name: en
alt: English
- flag: https://storage.yandexcloud.kz/sayauz-static/flags/gb.svg
+ flag: https://cdn.saya.uz/stats/flags/gb.svg
locFile: localization.en.yaml
auth:
db:
@@ -56,23 +56,26 @@ mail:
canonicalEndpoint: "https://${FQDN}"
photoStorage:
full:
- baseUrl: https://storage.yandexcloud.kz/sayauz-photos/%s
+ baseUrl: https://cdn.saya.uz/photos/jpeg-full/%s
webp:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-full/%s.webp
thumbnail1600p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp1600/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-1600p/%s.webp
thumbnail1200p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp1200/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-1200p/%s.webp
thumbnail800p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp800/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-800p/%s.webp
thumbnail560p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp560/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-560p/%s.webp
thumbnail320p:
- baseUrl: https://storage.yandexcloud.kz/sayauz-thumbnails-webp320/%s.webp
+ baseUrl: https://cdn.saya.uz/photos/webp-320p/%s.webp
homePageGifs:
- baseUrl: https://storage.yandexcloud.kz/sayauz-static/home-page-gifs/otter-%s.gif
+ baseUrl: https://cdn.saya.uz/stats/home-page-gifs/otter-%s.gif
indexes: ["1", "2", "3"]
staticStorage:
- baseUrl: https://storage.yandexcloud.kz/sayauz-static
+ baseUrl: https://cdn.saya.uz/stats
+ map:
+ baseUrl: https://cdn.saya.uz/map
+ pmTiles: global.pmtiles
allowOrigins:
- - https://storage.yandexcloud.kz
+ - https://cdn.saya.uz
diff --git a/deploy/playbook.yml b/deploy/playbook.yml
index ae3e936..057cc19 100644
--- a/deploy/playbook.yml
+++ b/deploy/playbook.yml
@@ -6,7 +6,7 @@
vars:
docker_volumes:
- "{{ saya_today_web_name }}-volume:/data:rw"
-
+
tasks:
- name: Initialize Unix socket volume
when: >-
@@ -26,11 +26,11 @@
community.docker.docker_volume:
name: "{{ saya_today_web_name }}-volume"
- - name: Deploy saya-today-web Docker Container
+ - name: Deploy sayauz/web Docker Container
community.docker.docker_container:
name: "{{ saya_today_web_name }}"
hostname: "{{ saya_today_web_hostname }}"
- image: "ghcr.io/sayaandy/saya-today-web:{{ saya_today_web_tag }}"
+ image: "registry.sayag.it/sayauz/web:{{ saya_today_web_tag }}"
env:
S3_ACCESS_KEY_ID: "{{ saya_today_web_s3_access_key_id }}"
S3_SECRET_ACCESS_KEY: "{{ saya_today_web_s3_secret_access_key }}"
@@ -42,8 +42,9 @@
MAIL_PASSWORD: "{{ saya_today_web_mail_password }}"
MAIL_SALT: "{{ saya_today_web_mail_salt }}"
FQDN: "{{ saya_today_web_listen_address }}"
- GOOGLE_SITE_VERIFICATION: "{{ saya_today_google_site_verification | default('') }}"
+ GOOGLE_VERIFICATION: "{{ saya_today_google_verification | default('') }}"
YANDEX_VERIFICATION: "{{ saya_today_yandex_verification | default('') }}"
+ BING_VERIFICATION: "{{ saya_today_bing_verification | default('') }}"
network_mode: caddy
networks:
- name: caddy
diff --git a/go.mod b/go.mod
index 779e641..4511ddb 100644
--- a/go.mod
+++ b/go.mod
@@ -5,15 +5,15 @@ go 1.26
require (
github.com/Backblaze/blazer v0.7.2
github.com/dgraph-io/ristretto/v2 v2.4.0
- github.com/go-co-op/gocron/v2 v2.21.1
+ github.com/go-co-op/gocron/v2 v2.21.2
github.com/go-playground/validator/v10 v10.30.2
github.com/gofiber/fiber/v2 v2.52.13
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/mattn/go-sqlite3 v1.14.44
- github.com/valyala/fasthttp v1.70.0
- github.com/wneessen/go-mail v0.7.2
+ github.com/valyala/fasthttp v1.71.0
+ github.com/wneessen/go-mail v0.7.3
github.com/yuin/goldmark v1.8.2
- golang.org/x/crypto v0.50.0
+ golang.org/x/crypto v0.51.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -40,7 +40,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
- github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/aws/smithy-go v1.25.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
@@ -58,6 +58,6 @@ require (
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
- golang.org/x/sys v0.43.0 // indirect
- golang.org/x/text v0.36.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
)
diff --git a/go.sum b/go.sum
index d277091..e6c5284 100644
--- a/go.sum
+++ b/go.sum
@@ -26,8 +26,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/ku
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.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.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/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
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.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
@@ -53,8 +53,8 @@ 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.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU=
-github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
+github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
+github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -104,22 +104,22 @@ 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=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
-github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
-github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
-github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
+github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
+github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
+github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
+github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
-golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
-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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/blog/b2.go b/internal/blog/b2.go
index 0caa0ba..f2ac890 100644
--- a/internal/blog/b2.go
+++ b/internal/blog/b2.go
@@ -2,6 +2,7 @@ package blog
import (
"context"
+ "encoding/json"
"fmt"
"slices"
"strings"
@@ -37,6 +38,20 @@ func NewB2Client(cfg *config.StorageConfig) (Client, error) {
return &B2Client{b2cl: b2cl, bucket: bucket, prefix: b2cfg.Prefix}, nil
}
+func (c *B2Client) GetMedleys() ([]MedleyEntry, error) {
+ idxRaw, err := c.readAll(MedleysIndexFileName)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", MedleysIndexFileName, err)
+ }
+
+ var idx []MedleyEntry
+ if err := json.Unmarshal(idxRaw, &idx); err != nil {
+ return nil, fmt.Errorf("unmarshal %s: %w", MedleysIndexFileName, err)
+ }
+
+ return idx, nil
+}
+
func (c *B2Client) Scan(prefix string) ([]*Page, error) {
filePaths := []*Page{}
@@ -70,17 +85,15 @@ func (c *B2Client) Scan(prefix string) ([]*Page, error) {
return nil, fmt.Errorf("failed to parse published time metadata field: %w", err)
}
- linkParts := strings.Split(obj.Name(), "/")
- nameParts := strings.Split(linkParts[len(linkParts)-1], ".")
- fileName := strings.Join(nameParts[:len(linkParts)-1], ".")
-
+ link := obj.Name()
+ fileName := link[strings.LastIndex(link, "/")+1 : strings.LastIndex(link, ".")]
tags := strings.Split(attrs.Info["tags"], ",")
slices.Sort(tags)
- lang, _ := strings.CutPrefix(linkParts[0], c.prefix)
+ lang, _ := strings.CutPrefix(link[0:strings.Index(link, "/")], c.prefix)
filePaths = append(filePaths, &Page{
- Link: obj.Name(),
+ Link: link,
FileName: fileName,
Lang: lang,
ModifiedTime: attrs.LastModified,
@@ -104,7 +117,11 @@ func (c *B2Client) Scan(prefix string) ([]*Page, error) {
}
func (c *B2Client) ReadAll(path string) ([]byte, error) {
- obj := c.bucket.Object(c.prefix + path)
+ return c.readAll(c.prefix + path)
+}
+
+func (c *B2Client) readAll(path string) ([]byte, error) {
+ obj := c.bucket.Object(path)
if obj == nil {
return nil, fmt.Errorf("failed to reference object in B2 bucket")
}
diff --git a/internal/blog/client.go b/internal/blog/client.go
index 56bbee4..53eb0fd 100644
--- a/internal/blog/client.go
+++ b/internal/blog/client.go
@@ -17,6 +17,7 @@ type Page struct {
type Client interface {
Scan(prefix string) ([]*Page, error)
+ GetMedleys() ([]MedleyEntry, error)
ReadAll(path string) ([]byte, error)
ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error)
}
diff --git a/internal/blog/index.go b/internal/blog/index.go
index 861a90e..a09b59e 100644
--- a/internal/blog/index.go
+++ b/internal/blog/index.go
@@ -1,10 +1,17 @@
package blog
-import "time"
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+)
const IndexFileName = "index.json"
+const MedleysIndexFileName = "medleys.json"
-const IndexSchemaVersion = 1
+const IndexSchemaVersion = 2
type IndexEntry struct {
Link string `json:"link"`
@@ -20,13 +27,77 @@ type IndexEntry struct {
MedleyPart int `json:"medleyPart,omitempty"`
}
-type IndexCategory struct {
+func (e IndexEntry) Metadata() *frontmatter.Metadata {
+ return &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,
+ }
+}
+
+type IndexV2Category struct {
+ GeneratedAt time.Time `json:"generatedAt"`
+ Pages map[string]IndexEntry `json:"pages"`
+}
+
+type IndexV1Category 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"`
+ SchemaVersion int `json:"schemaVersion"`
+ GeneratedAt time.Time `json:"generatedAt"`
+ Categories any `json:"categories"`
+}
+
+func (idx *Index) UnmarshalJSON(data []byte) error {
+ var tmp struct {
+ SchemaVersion int `json:"schemaVersion"`
+ GeneratedAt time.Time `json:"generatedAt"`
+ Categories json.RawMessage `json:"categories"`
+ }
+
+ if err := json.Unmarshal(data, &tmp); err != nil {
+ return err
+ }
+
+ idx.SchemaVersion = tmp.SchemaVersion
+ idx.GeneratedAt = tmp.GeneratedAt
+
+ switch tmp.SchemaVersion {
+ case 1:
+ var categories map[string]*IndexV1Category
+ if err := json.Unmarshal(tmp.Categories, &categories); err != nil {
+ return fmt.Errorf("unmarshal map[string]*IndexV1Category: %w", err)
+ }
+ idx.Categories = &categories
+ case 2:
+ var categories map[string]*IndexV2Category
+ if err := json.Unmarshal(tmp.Categories, &categories); err != nil {
+ return fmt.Errorf("unmarshal map[string]*IndexV2Category: %w", err)
+ }
+ idx.Categories = &categories
+ default:
+ return fmt.Errorf("unsupported index version: %d", tmp.SchemaVersion)
+ }
+
+ return nil
+}
+
+type MedleyEntry struct {
+ Codename string `json:"codename"`
+ Localnames map[string]string `json:"localnames"`
+ Content []string `json:"content"`
+}
+
+type MedleyPageEntry struct {
+ Codename string `json:"codename"`
+ Position int `json:"position"`
}
diff --git a/internal/blog/s3.go b/internal/blog/s3.go
index bbd5238..3c1a932 100644
--- a/internal/blog/s3.go
+++ b/internal/blog/s3.go
@@ -1,29 +1,22 @@
package blog
import (
+ "bytes"
"context"
"encoding/json"
- "errors"
"fmt"
"io"
- "log/slog"
- "net/url"
- "slices"
"strings"
- "sync"
- "time"
"github.com/SayaAndy/saya-today-web/config"
"github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/aws/aws-sdk-go-v2/aws"
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
@@ -66,39 +59,38 @@ func NewS3Client(cfg *config.StorageConfig) (Client, error) {
return &S3Client{s3cfg.Prefix, s3cfg.BucketName, s3cl}, nil
}
-func (c *S3Client) Scan(prefix string) ([]*Page, error) {
- pages, err := c.scanFromIndex(prefix)
- if err == nil {
- return pages, nil
+func (c *S3Client) GetMedleys() ([]MedleyEntry, error) {
+ idxRaw, err := c.readAll(MedleysIndexFileName)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", MedleysIndexFileName, err)
}
- var nsk *s3types.NoSuchKey
- if !errors.As(err, &nsk) {
- return nil, err
+ var idx []MedleyEntry
+ if err := json.Unmarshal(idxRaw, &idx); err != nil {
+ return nil, fmt.Errorf("unmarshal %s: %w", MedleysIndexFileName, err)
}
- slog.Warn("index.json missing, falling back to listing", slog.String("prefix", c.prefix))
- return c.scanByListing(prefix)
+ return idx, nil
}
-func (c *S3Client) scanFromIndex(prefix string) ([]*Page, error) {
+func (c *S3Client) Scan(prefix string) ([]*Page, error) {
out, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(c.bucketName),
Key: aws.String(IndexFileName),
})
if err != nil {
- return nil, fmt.Errorf("get index.json: %w", err)
+ return nil, fmt.Errorf("get %s: %w", IndexFileName, err)
}
defer out.Body.Close()
raw, err := io.ReadAll(out.Body)
if err != nil {
- return nil, fmt.Errorf("read index.json: %w", err)
+ return nil, fmt.Errorf("read %s: %w", IndexFileName, err)
}
var idx Index
if err := json.Unmarshal(raw, &idx); err != nil {
- return nil, fmt.Errorf("unmarshal index.json: %w", err)
+ return nil, fmt.Errorf("unmarshal %s: %w", IndexFileName, err)
}
wantLang := ""
@@ -108,160 +100,93 @@ func (c *S3Client) scanFromIndex(prefix string) ([]*Page, error) {
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) {
+
+ switch idx.SchemaVersion {
+ case 1:
+ for catKey, cat := range *idx.Categories.(*map[string]*IndexV1Category) {
+ lang, ok := strings.CutPrefix(catKey, c.prefix)
+ if !ok {
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
-}
-
-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") {
+ if wantLang != "" && wantLang != lang {
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(cand.key),
- })
- if err != nil {
- errMu.Lock()
- if firstErr == nil {
- firstErr = fmt.Errorf("head S3 object %s: %w", cand.key, err)
+ for _, e := range cat.Pages {
+ if !strings.HasPrefix(e.Link, fullPrefix) {
+ continue
}
- errMu.Unlock()
- return
+ fileName := e.Link[strings.LastIndex(e.Link, "/")+1 : strings.LastIndex(e.Link, ".")]
+ 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,
+ },
+ })
}
-
- if head.ContentType == nil || !strings.Contains(*head.ContentType, "text/markdown") {
- return
+ }
+ case 2:
+ for catKey, cat := range *idx.Categories.(*map[string]*IndexV2Category) {
+ lang, ok := strings.CutPrefix(catKey, c.prefix)
+ if !ok {
+ continue
}
- meta := head.Metadata
- if meta["title"] == "" {
- return
+ if wantLang != "" && wantLang != lang {
+ continue
}
-
- publishedTime, err := time.Parse(time.RFC3339, meta["published-time"])
- if err != nil {
- errMu.Lock()
- if firstErr == nil {
- firstErr = fmt.Errorf("failed to parse published time metadata field: %w", err)
+ for codename, e := range cat.Pages {
+ if !strings.HasPrefix(e.Link, fullPrefix) {
+ continue
}
- errMu.Unlock()
- return
+ pages = append(pages, &Page{
+ Link: e.Link,
+ FileName: codename,
+ 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,
+ },
+ })
}
-
- 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)
-
- title, _ := url.QueryUnescape(meta["title"])
- shortDescription, _ := url.QueryUnescape(meta["short-description"])
- thumbnail, _ := url.QueryUnescape(meta["thumbnail"])
-
- pages[i] = &Page{
- Link: cand.key,
- FileName: fileName,
- Lang: lang,
- ModifiedTime: cand.lastModified,
- Metadata: &frontmatter.Metadata{
- Title: title,
- ShortDescription: shortDescription,
- ActionDate: meta["action-date"],
- PublishedTime: publishedTime,
- Thumbnail: thumbnail,
- Tags: tags,
- Geolocation: meta["geolocation"],
- },
- }
- })
- }
- wg.Wait()
-
- if firstErr != nil {
- return nil, firstErr
+ }
}
- out := pages[:0]
- for _, p := range pages {
- if p != nil {
- out = append(out, p)
+ medleys, _ := c.GetMedleys()
+ for _, medley := range medleys {
+ for locale, localname := range medley.Localnames {
+ l10n.T.SetPath(localname, true, locale, "Medleys", medley.Codename)
}
}
- return out, nil
+
+ return pages, nil
}
func (c *S3Client) ReadAll(path string) ([]byte, error) {
+ return c.readAll(c.prefix + path)
+}
+
+func (c *S3Client) readAll(path string) ([]byte, error) {
output, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(c.bucketName),
- Key: aws.String(c.prefix + path),
+ Key: aws.String(path),
})
if err != nil {
return nil, fmt.Errorf("get S3 object: %w", err)
@@ -277,10 +202,40 @@ func (c *S3Client) ReadAll(path string) ([]byte, error) {
}
func (c *S3Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) {
+ idxRaw, err := c.readAll(IndexFileName)
+ if err != nil {
+ return nil, nil, fmt.Errorf("read %s: %w", IndexFileName, err)
+ }
+
+ var idx Index
+ if err := json.Unmarshal(idxRaw, &idx); err != nil {
+ return nil, nil, fmt.Errorf("unmarshal %s: %w", IndexFileName, err)
+ }
+
contentBytes, err := c.ReadAll(path)
if err != nil {
return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err)
}
+ switch idx.SchemaVersion {
+ case 1:
+ return frontmatter.ParseFrontmatter(contentBytes)
+ case 2:
+ fullPath := c.prefix + path
+ page := (*idx.Categories.(*map[string]*IndexV2Category))[fullPath[:strings.LastIndex(fullPath, "/")]].Pages[fullPath[strings.LastIndex(fullPath, "/")+1:strings.LastIndex(fullPath, ".")]]
+ metadata = page.Metadata()
+
+ if !bytes.HasPrefix(contentBytes, []byte("---\n")) {
+ return metadata, contentBytes, nil
+ }
+
+ end := bytes.Index(contentBytes[4:], []byte("\n---\n"))
+ if end == -1 {
+ return metadata, contentBytes, nil
+ }
+
+ return metadata, contentBytes[end+9:], nil
+ }
+
return frontmatter.ParseFrontmatter(contentBytes)
}
diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go
index b2cd855..1bb606b 100644
--- a/internal/glightbox/html_renderer.go
+++ b/internal/glightbox/html_renderer.go
@@ -151,12 +151,12 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte,
w.WriteString(strings.ReplaceAll(`
<div class="items-center flex flex-col">
- <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto">
+ <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 ml-auto mr-auto">
<div class="grid masonry-grid-{id}">
<div class="grid-sizer grid-sizer-{id}"></div>
`+strings.Join(elements, "\n")+`
</div>
- <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto">
+ <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 ml-auto mr-auto">
</div>
<script>
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
index ffa7b30..33db4f7 100644
--- a/internal/mailer/mailer.go
+++ b/internal/mailer/mailer.go
@@ -16,7 +16,7 @@ import (
"github.com/SayaAndy/saya-today-web/internal/blog"
"github.com/SayaAndy/saya-today-web/internal/templatemanager"
- "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/dgraph-io/ristretto/v2"
"github.com/gofiber/fiber/v2"
"github.com/wneessen/go-mail"
@@ -43,8 +43,6 @@ type Mailer struct {
hashMap map[string][]byte
hashMapMutex sync.RWMutex
-
- l map[string]*locale.LocaleConfig
}
type SubscriptionType int
@@ -55,7 +53,7 @@ const (
Specific
)
-func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) {
+func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte) (*Mailer, error) {
verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, string]{
NumCounters: 10000,
MaxCost: 1 << 20, // 1 MB
@@ -111,7 +109,7 @@ func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string
End time.Time
CodeExpiry time.Time
}, 0),
- l: localization}, nil
+ }, nil
}
func (m *Mailer) GetHash(id string) []byte {
@@ -261,10 +259,9 @@ func (m *Mailer) SendVerificationCode(userId string, address string, lang string
verificationInfo := fmt.Sprintf("%s.%s", base64.RawStdEncoding.EncodeToString([]byte(userId)), base64.RawStdEncoding.EncodeToString([]byte(address)))
m.verificationCodes.Set(verificationCode, verificationInfo, int64(len(verificationInfo)+8))
- message.Subject(m.l[lang].Mail.VerifyEmail.Subject)
+ message.Subject(l10n.T.GetPath(lang, "Mail", "VerifyEmail", "Subject").(string))
msg, err := m.tm.Render("verify-email", fiber.Map{
- "L": m.l[lang],
"Lang": lang,
"VerificationCode": fmt.Sprintf("%X", verificationCode),
"ClientHost": m.clientHost,
@@ -499,11 +496,10 @@ rowLoop:
rand.Read(unsubscribeCodeBytes)
unsubscribeCode := binary.LittleEndian.Uint64(unsubscribeCodeBytes)
- unsubscribeFooter := strings.Replace(m.l[post.Lang].Mail.UnsubscribeFooter, "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1)
+ unsubscribeFooter := strings.Replace(l10n.T.GetPath(post.Lang, "Mail", "UnsubscribeFooter").(string), "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1)
unsubscribeFooter = strings.Replace(unsubscribeFooter, "{/}", "</a>", 1)
msgBody, err := m.tm.Render("new-post", fiber.Map{
- "L": m.l[post.Lang],
"Lang": post.Lang,
"Post": post,
"ClientHost": m.clientHost,
@@ -529,7 +525,7 @@ rowLoop:
message.SetMessageID()
message.SetDate()
message.SetBulk()
- message.Subject(m.l[post.Lang].Mail.NewPost.Subject)
+ message.Subject(l10n.T.GetPath(post.Lang, "Mail", "NewPost", "Subject").(string))
message.SetBodyString(mail.TypeTextHTML, string(msgBody))
m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40)
diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go
index c48b40a..b59f09a 100644
--- a/internal/router/handlers/api-v1-blog-search.go
+++ b/internal/router/handlers/api-v1-blog-search.go
@@ -8,7 +8,6 @@ import (
"net/url"
"regexp"
"slices"
- "strings"
"time"
"github.com/SayaAndy/saya-today-web/internal/blog"
@@ -53,12 +52,7 @@ func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements
sort := c.Query("sort")
tz := c.Query("tz")
medley := c.Query("medley")
- referer := c.Get("Referer")
- refererParts := strings.Split(referer, "/")
- refererPage := ""
- if len(refererParts) >= 1 {
- refererPage = refererParts[len(refererParts)-1]
- }
+ highlight := c.Query("highlight")
hideTags := c.QueryBool("hideTags", false)
hidePublishedTime := c.QueryBool("hidePublishedTime", false)
@@ -111,7 +105,7 @@ func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements
"Viewed": supplements.ClientCache.GetViewStatus(c.IP(), page.FileName),
"Medley": page.Metadata.Medley,
"MedleyPart": page.Metadata.MedleyPart,
- "ToHighlight": page.FileName == refererPage,
+ "ToHighlight": page.FileName == highlight,
})
break
}
diff --git a/internal/router/handlers/api-v1-email-is-in-verification.go b/internal/router/handlers/api-v1-email-is-in-verification.go
index 4f05f48..b8ae162 100644
--- a/internal/router/handlers/api-v1-email-is-in-verification.go
+++ b/internal/router/handlers/api-v1-email-is-in-verification.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -49,7 +50,7 @@ func (r *OngoingVerificationHandler) Render(c *fiber.Ctx, supplements *router.Su
if !isAllowed {
sterileDataset["striked-end-time"] = template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli()))
templateMap["Status"] = "Neutral"
- templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
+ templateMap["Message"] = strings.ReplaceAll(l10n.T.GetPath(lang, "UserProfile", "DelayTilVerification").(string), "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
} else {
templateMap["Status"] = "OK"
templateMap["Message"] = ""
diff --git a/internal/router/handlers/api-v1-email-send-verification-code.go b/internal/router/handlers/api-v1-email-send-verification-code.go
index 0b53efa..ea8df4d 100644
--- a/internal/router/handlers/api-v1-email-send-verification-code.go
+++ b/internal/router/handlers/api-v1-email-send-verification-code.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -49,27 +50,27 @@ func (r *SendVerificationCodeHandler) Render(c *fiber.Ctx, supplements *router.S
email := c.FormValue("email")
if email == "" {
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailEmpty
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "EmailEmpty").(string)
return fiber.StatusUnprocessableEntity, nil
}
isTaken, err := supplements.Mailer.MailIsTaken(email)
if err != nil {
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSendingError").(string)
slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
return fiber.StatusUnprocessableEntity, nil
}
if isTaken {
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailTaken
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "EmailTaken").(string)
return fiber.StatusUnprocessableEntity, nil
}
if isAllowed, whenAllowed, _ := supplements.Mailer.IsAllowedToRetryVerification(id); !isAllowed {
templateMap["Status"] = "Failed"
- templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
+ templateMap["Message"] = strings.ReplaceAll(l10n.T.GetPath(lang, "UserProfile", "DelayTilVerification").(string), "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
templateMap["DataAttributes"] = map[string]any{
"striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli())),
}
@@ -78,20 +79,20 @@ func (r *SendVerificationCodeHandler) Render(c *fiber.Ctx, supplements *router.S
if previousEmail, _, _ := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(id)); previousEmail == email {
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailAlreadyValidated
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "EmailAlreadyValidated").(string)
return fiber.StatusUnprocessableEntity, nil
}
if err = supplements.Mailer.SendVerificationCode(id, email, lang); err != nil {
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSendingError").(string)
slog.Error("failed to send a verification code", slog.String("error", err.Error()))
return fiber.StatusUnprocessableEntity, nil
}
_, endTime, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(id)
templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSent
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSent").(string)
templateMap["DataAttributes"] = map[string]any{
"striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", endTime.UnixMilli())),
"code-expiry-time": template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli())),
diff --git a/internal/router/handlers/api-v1-email-verify.go b/internal/router/handlers/api-v1-email-verify.go
index ace1870..65ad6d9 100644
--- a/internal/router/handlers/api-v1-email-verify.go
+++ b/internal/router/handlers/api-v1-email-verify.go
@@ -5,6 +5,7 @@ import (
"log/slog"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -46,19 +47,19 @@ func (r *VerifyCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements
if verificationCode == "" {
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationEmpty
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationEmpty").(string)
return fiber.StatusUnprocessableEntity, nil
}
if err = supplements.Mailer.Verify(verificationCode, lang); err != nil {
slog.Warn("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationFailed
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationFailed").(string)
return fiber.StatusUnprocessableEntity, nil
}
templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationSuccess
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationSuccess").(string)
templateMap["DataAttributes"] = map[string]any{
"hide-verification-panel": template.HTMLAttr("data-code-expiry-time=\"true\""),
}
diff --git a/internal/router/handlers/api-v1-like-get.go b/internal/router/handlers/api-v1-like-get.go
index 0eaf5f6..9e2c856 100644
--- a/internal/router/handlers/api-v1-like-get.go
+++ b/internal/router/handlers/api-v1-like-get.go
@@ -42,7 +42,7 @@ func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, l
return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
}
- if len(pathParts) != 3 && pathParts[1] != "blog" {
+ if len(pathParts) != 3 || pathParts[1] != "blog" {
return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
}
diff --git a/internal/router/handlers/api-v1-map-get.go b/internal/router/handlers/api-v1-map-get.go
new file mode 100644
index 0000000..e19c54a
--- /dev/null
+++ b/internal/router/handlers/api-v1-map-get.go
@@ -0,0 +1,116 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/blog"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type GetMapHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &GetMapHandler{})
+}
+
+func (r *GetMapHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/map"
+}
+
+func (r *GetMapHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *GetMapHandler) TemplatesToInject() []string {
+ return []string{"views/partials/global-map-widget.html"}
+}
+
+func (r *GetMapHandler) ToCache() router.CacheSetting {
+ return router.ByUrlAndQuery
+}
+
+func (r *GetMapHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *GetMapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ codename := c.Query("codename")
+ zoom := c.QueryInt("zoom", 4)
+ zoomPosition := c.Query("zoomPosition")
+
+ pages, err := supplements.BlogClient.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ slog.Error("received an error while scanning blog pages",
+ slog.String("error", err.Error()),
+ slog.String("lang", lang),
+ )
+ status = fiber.StatusPartialContent
+ pages = []*blog.Page{}
+ }
+ slices.SortFunc(pages, func(a *blog.Page, b *blog.Page) int {
+ return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime)
+ })
+
+ type MapMarker struct {
+ Index int `json:"Index"`
+ Title string `json:"Title"`
+ PageLink string `json:"PageLink"`
+ Lat float64 `json:"Lat"`
+ Long float64 `json:"Long"`
+ AccuracyMeters int64 `json:"AccuracyMeters"`
+ Thumbnail string `json:"Thumbnail"`
+ ToHighlight bool `json:"ToHighlight"`
+ }
+
+ templateMap["MapLocationLat"] = 45.4507
+ templateMap["MapLocationLong"] = 68.8319
+ templateMap["MapLocationZoom"] = zoom
+ templateMap["ZoomPosition"] = zoomPosition
+
+ mapMarkers := make([]*MapMarker, 0, len(pages))
+ for i, page := range pages {
+ geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
+ if len(geolocationParts) < 2 {
+ continue
+ }
+
+ var x, y float64
+ var areaError int64
+ if len(geolocationParts) >= 2 {
+ x, _ = strconv.ParseFloat(geolocationParts[0], 64)
+ y, _ = strconv.ParseFloat(geolocationParts[1], 64)
+ }
+ if len(geolocationParts) >= 3 {
+ areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
+ }
+
+ toHighlight := page.FileName == codename
+ if toHighlight {
+ templateMap["MapLocationLat"] = x
+ templateMap["MapLocationLong"] = y
+ }
+
+ mapMarkers = append(mapMarkers, &MapMarker{
+ Index: i,
+ Title: page.Metadata.Title,
+ PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
+ Lat: x,
+ Long: y,
+ AccuracyMeters: areaError,
+ Thumbnail: page.Metadata.Thumbnail,
+ ToHighlight: toHighlight,
+ })
+ }
+
+ templateMap["MapMarkers"] = mapMarkers
+
+ return status, nil
+}
diff --git a/internal/router/handlers/api-v1-subs-put.go b/internal/router/handlers/api-v1-subs-put.go
index 32fba7e..12dc5cf 100644
--- a/internal/router/handlers/api-v1-subs-put.go
+++ b/internal/router/handlers/api-v1-subs-put.go
@@ -3,6 +3,7 @@ package handlers
import (
"github.com/SayaAndy/saya-today-web/internal/mailer"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -52,18 +53,18 @@ func (r *PutSubsHandler) Render(c *fiber.Ctx, supplements *router.Supplements, l
subscriptionTypeEnum = mailer.Specific
default:
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribeInvalidType
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "SubscribeInvalidType").(string)
return fiber.StatusUnprocessableEntity, nil
}
specificTags := c.FormValue("tags_picked")
if err = supplements.Mailer.Subscribe(supplements.Mailer.GetHash(c.IP()), subscriptionTypeEnum, specificTags); err != nil {
templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.FailedToSubscribe
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "FailedToSubscribe").(string)
return fiber.StatusUnprocessableEntity, nil
}
templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribedSuccessfully
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "SubscribedSuccessfully").(string)
return fiber.StatusOK, nil
}
diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go
index f7cd019..76dc335 100644
--- a/internal/router/handlers/lang-blog-title.go
+++ b/internal/router/handlers/lang-blog-title.go
@@ -10,6 +10,7 @@ import (
"github.com/SayaAndy/saya-today-web/internal/blog"
"github.com/SayaAndy/saya-today-web/internal/frontmatter"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
"github.com/yuin/goldmark"
)
@@ -70,11 +71,15 @@ func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements,
if err != nil {
return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err)
}
+ title := metadata.Title
+ if metadata.Medley != "" {
+ title += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string)
+ }
return []router.MetaField{
- {Property: "og:title", Content: metadata.Title},
+ {Property: "og:title", Content: title},
{Property: "og:description", Content: fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate)},
- {Property: "og:image", Content: fmt.Sprintf(supplements.PhotoStorage.Thumbnail320p.BaseUrl, metadata.Thumbnail)},
+ {Property: "og:image", Content: fmt.Sprintf(supplements.PhotoStorage.Thumbnail560p.BaseUrl, metadata.Thumbnail)},
{Property: "og:url", Content: fmt.Sprintf("%s/%s/blog/%s", templateMap["CanonicalEndpoint"], lang, c.Params("title"))},
{Property: "og:type", Content: "website"},
{Name: "twitter:card", Content: "summary_large_image"},
@@ -86,11 +91,15 @@ func (r *BlogPageHandler) AddLinkedData(c *fiber.Ctx, supplements *router.Supple
if err != nil {
return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err)
}
+ title := metadata.Title
+ if metadata.Medley != "" {
+ title += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string)
+ }
return map[string]any{
"@context": "https://schema.org",
"@type": "Article",
- "headline": metadata.Title,
+ "headline": title,
"description": fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate),
"author": map[string]string{"@type": "Person", "name": "Saya Andy"},
"datePublished": metadata.PublishedTime.UTC().Format(time.RFC3339),
@@ -102,10 +111,11 @@ func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplemen
if err != nil {
return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
}
+ title := pathParts[2]
- metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.BlogClient, lang+"/"+pathParts[2])
+ metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.BlogClient, lang+"/"+title)
if err != nil {
- return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", pathParts[2], err)
+ return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", title, err)
}
geolocationParts := strings.Split(metadata.Geolocation, " ")
@@ -122,6 +132,7 @@ func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplemen
templateMap["MapLocationY"] = y
templateMap["MapLocationAreaMeters"] = areaError
templateMap["Title"] = metadata.Title
+ templateMap["Codename"] = title
templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown)
templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
templateMap["ActionDate"] = metadata.ActionDate
@@ -129,7 +140,7 @@ func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplemen
templateMap["Thumbnail"] = metadata.Thumbnail
templateMap["Medley"] = metadata.Medley
- go supplements.ClientCache.View(c.IP(), pathParts[2])
+ go supplements.ClientCache.View(c.IP(), title)
return fiber.StatusOK, nil
}
@@ -145,7 +156,13 @@ func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplem
return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err)
}
+ pageTitle := metadata.Title
+ if metadata.Medley != "" {
+ pageTitle += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string)
+ }
+
templateMap["Title"] = metadata.Title
+ templateMap["PageTitle"] = pageTitle
return fiber.StatusOK, nil
}
diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go
index 79ec9f3..c31c2eb 100644
--- a/internal/router/handlers/lang-blog.go
+++ b/internal/router/handlers/lang-blog.go
@@ -11,6 +11,7 @@ import (
"github.com/SayaAndy/saya-today-web/internal/blog"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -57,18 +58,16 @@ func (r *CatalogueHandler) SitemapInfo(supplements *router.Supplements) []router
func (r *CatalogueHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
return []router.MetaField{
- {Property: "og:title", Content: supplements.Localization[lang].BlogSearch.Header},
- {Property: "og:description", Content: supplements.Localization[lang].BlogSearch.Description},
+ {Property: "og:title", Content: l10n.T.GetPath(lang, "BlogSearch", "Header").(string)},
+ {Property: "og:description", Content: l10n.T.GetPath(lang, "BlogSearch", "Description").(string)},
{Property: "og:url", Content: fmt.Sprintf("%s/%s/blog", templateMap["CanonicalEndpoint"], lang)},
{Property: "og:type", Content: "website"},
}, nil
}
func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- querySort := c.Query("sort")
- if querySort == "" {
- querySort = "publicationDateDesc"
- }
+ querySort := c.Query("sort", "publicationDateDesc")
+ previousBlogPage := c.Query("codename")
encodedQuery := c.Request().URI().QueryString()
decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
@@ -87,13 +86,14 @@ func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Suppleme
templateMap["Tags"] = tagsArray
templateMap["QuerySort"] = querySort
templateMap["QueryTags"] = strings.Join(queryTags, ",")
- templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
+ templateMap["Title"] = l10n.T.GetPath(lang, "BlogSearch", "Header").(string)
+ templateMap["PreviousBlogPage"] = previousBlogPage
return fiber.StatusOK, nil
}
func (r *CatalogueHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
+ templateMap["Title"] = l10n.T.GetPath(lang, "BlogSearch", "Header").(string)
return fiber.StatusOK, nil
}
diff --git a/internal/router/handlers/lang-map.go b/internal/router/handlers/lang-map.go
index d452bc7..32ab35e 100644
--- a/internal/router/handlers/lang-map.go
+++ b/internal/router/handlers/lang-map.go
@@ -1,13 +1,6 @@
package handlers
import (
- "fmt"
- "log/slog"
- "slices"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/blog"
"github.com/SayaAndy/saya-today-web/internal/router"
"github.com/gofiber/fiber/v2"
)
@@ -49,61 +42,5 @@ func (r *MapHandler) SitemapInfo(supplements *router.Supplements) []router.Sitem
}
func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- pages, err := supplements.BlogClient.Scan(lang + "/")
- slices.SortFunc(pages, func(a *blog.Page, b *blog.Page) int {
- return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime)
- })
- status := fiber.StatusOK
- if err != nil {
- slog.Error("received an error while scanning b2 pages",
- slog.String("error", err.Error()),
- slog.String("lang", lang),
- )
- status = fiber.StatusPartialContent
- pages = []*blog.Page{}
- }
-
- type MapMarker struct {
- Index int `json:"Index"`
- Title string `json:"Title"`
- PageLink string `json:"PageLink"`
- Lat float64 `json:"Lat"`
- Long float64 `json:"Long"`
- AccuracyMeters int64 `json:"AccuracyMeters"`
- Thumbnail string `json:"Thumbnail"`
- }
-
- mapMarkers := make([]*MapMarker, 0, len(pages))
- for i, page := range pages {
- geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
- if len(geolocationParts) < 2 {
- continue
- }
-
- var x, y float64
- var areaError int64
- if len(geolocationParts) >= 2 {
- x, _ = strconv.ParseFloat(geolocationParts[0], 64)
- y, _ = strconv.ParseFloat(geolocationParts[1], 64)
- }
- if len(geolocationParts) >= 3 {
- areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
- }
-
- mapMarkers = append(mapMarkers, &MapMarker{
- Index: i,
- Title: page.Metadata.Title,
- PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
- Lat: x,
- Long: y,
- AccuracyMeters: areaError,
- Thumbnail: page.Metadata.Thumbnail,
- })
- }
-
- templateMap["MapMarkers"] = mapMarkers
- templateMap["MapLocationLat"] = 45.4507
- templateMap["MapLocationLong"] = 68.8319
-
- return status, nil
+ return fiber.StatusOK, nil
}
diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go
index 4fbd244..e22d854 100644
--- a/internal/router/handlers/lang-user-unsubscribe.go
+++ b/internal/router/handlers/lang-user-unsubscribe.go
@@ -4,6 +4,7 @@ import (
"log/slog"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -39,24 +40,24 @@ func (r *UnsubscribeHandler) Render(c *fiber.Ctx, supplements *router.Supplement
if unsubscribeCode == "" {
statusColor = "0, 0, 255"
statusEmoji = "(╭ರ_•́)"
- statusText = supplements.Localization[lang].UnsubscribePage.UnsetCode
+ statusText = l10n.T.GetPath(lang, "UnsubscribePage", "UnsetCode").(string)
status = fiber.ErrBadRequest.Code
} else if clientError, serverError := supplements.Mailer.Unsubscribe(unsubscribeCode); clientError != nil {
slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error()))
statusColor = "255, 0, 0"
statusEmoji = "(͠≖~≖ ͡ )"
- statusText = supplements.Localization[lang].UnsubscribePage.InvalidCode
+ statusText = l10n.T.GetPath(lang, "UnsubscribePage", "InvalidCode").(string)
status = fiber.ErrBadRequest.Code
} else if serverError != nil {
slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error()))
statusColor = "255, 128, 0"
statusEmoji = "( ˶°ㅁ°) !!"
- statusText = supplements.Localization[lang].UnsubscribePage.OnServerError
+ statusText = l10n.T.GetPath(lang, "UnsubscribePage", "OnServerError").(string)
status = fiber.ErrInternalServerError.Code
} else {
statusColor = "0, 255, 0"
statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
- statusText = supplements.Localization[lang].UnsubscribePage.Success
+ statusText = l10n.T.GetPath(lang, "UnsubscribePage", "Success").(string)
status = fiber.StatusOK
}
diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go
index 0dda529..085a9aa 100644
--- a/internal/router/handlers/lang-user.go
+++ b/internal/router/handlers/lang-user.go
@@ -6,6 +6,7 @@ import (
"github.com/SayaAndy/saya-today-web/internal/mailer"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -40,15 +41,15 @@ func (r *UserHandler) ToValidateLang() router.LangSetting {
func (r *UserHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
return []router.MetaField{
{Name: "robots", Content: "noindex,nofollow"},
- {Property: "og:title", Content: supplements.Localization[lang].UserProfile.Header},
- {Property: "og:description", Content: supplements.Localization[lang].UserProfile.Description},
+ {Property: "og:title", Content: l10n.T.GetPath(lang, "UserProfile", "Header").(string)},
+ {Property: "og:description", Content: l10n.T.GetPath(lang, "UserProfile", "Description").(string)},
{Property: "og:url", Content: fmt.Sprintf("%s/%s/user", templateMap["CanonicalEndpoint"], lang)},
{Property: "og:type", Content: "website"},
}, nil
}
func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
+ templateMap["Title"] = l10n.T.GetPath(lang, "UserProfile", "Header").(string)
email, _, err := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(c.IP()))
if err != nil {
@@ -83,6 +84,6 @@ func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements,
}
func (r *UserHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
+ templateMap["Title"] = l10n.T.GetPath(lang, "UserProfile", "Header").(string)
return fiber.StatusOK, nil
}
diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go
index 25b5036..cc0b33b 100644
--- a/internal/router/handlers/lang.go
+++ b/internal/router/handlers/lang.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
"github.com/gofiber/fiber/v2"
)
@@ -51,8 +52,8 @@ func (r *HomeHandler) SitemapInfo(supplements *router.Supplements) []router.Site
func (r *HomeHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
return []router.MetaField{
- {Property: "og:title", Content: supplements.Localization[lang].HomePage.Header},
- {Property: "og:description", Content: supplements.Localization[lang].HomePage.HomePageDescription},
+ {Property: "og:title", Content: l10n.T.GetPath(lang, "HomePage", "Header").(string)},
+ {Property: "og:description", Content: l10n.T.GetPath(lang, "HomePage", "HomePageDescription").(string)},
{Property: "og:image", Content: fmt.Sprintf(
supplements.PhotoStorage.HomePageGifs.BaseUrl,
supplements.PhotoStorage.HomePageGifs.Indexes[rand.Int()%len(supplements.PhotoStorage.HomePageGifs.Indexes)],
@@ -63,7 +64,7 @@ func (r *HomeHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lan
}
func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].HomePage.Header
+ templateMap["Title"] = l10n.T.GetPath(lang, "HomePage", "Header").(string)
templateMap["FilledHeartCount"] = uint(40)
templateMap["OutlineHeartCount"] = uint(40)
templateMap["GifUrl"] = fmt.Sprintf(
@@ -75,6 +76,6 @@ func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements,
}
func (r *HomeHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].HomePage.Header
+ templateMap["Title"] = l10n.T.GetPath(lang, "HomePage", "Header").(string)
return fiber.StatusOK, nil
}
diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go
index 6a011e4..4fc4e31 100644
--- a/internal/router/handlers/root.go
+++ b/internal/router/handlers/root.go
@@ -47,11 +47,8 @@ func (r *RootHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lan
{Property: "og:url", Content: fmt.Sprint(templateMap["CanonicalEndpoint"]) + "/"},
{Property: "og:type", Content: "website"},
}
- 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})
+ for _, field := range supplements.Meta {
+ meta = append(meta, router.MetaField{Name: field.Name, Content: field.Value})
}
return meta, nil
}
diff --git a/internal/router/router.go b/internal/router/router.go
index 7f3511a..a11a934 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -23,7 +23,6 @@ import (
"github.com/SayaAndy/saya-today-web/internal/mailer"
"github.com/SayaAndy/saya-today-web/internal/tailwind"
"github.com/SayaAndy/saya-today-web/internal/templatemanager"
- "github.com/SayaAndy/saya-today-web/locale"
"github.com/dgraph-io/ristretto/v2"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
@@ -95,7 +94,6 @@ type Route interface {
type Supplements struct {
DB *sql.DB
BlogClient blog.Client
- Localization map[string]*locale.LocaleConfig
AvailableLanguages []config.AvailableLanguageConfig
ClientCache *ClientCache
PageCache *ristretto.Cache[string, []byte]
@@ -104,9 +102,9 @@ type Supplements struct {
BlogTrigger *blogtrigger.BlogTriggerScheduler
TemplateManager *templatemanager.TemplateManager
MarkdownRenderer goldmark.Markdown
- Meta config.MetaConfig
+ Meta []config.MetaConfig
PhotoStorage config.PhotoStorageConfig
- StaticStorage config.PhotoTypeConfig
+ StaticStorage config.StaticStorageConfig
}
type Router struct {
@@ -149,16 +147,7 @@ func NewRouter(cfg *config.Config) (*Router, error) {
supplements.BlogClient, err = blog.NewClientMap[cfg.BlogPages.Storage.Type](&cfg.BlogPages.Storage)
if err != nil {
- return nil, fmt.Errorf("fail to initialize b2 client: %w", err)
- }
-
- supplements.Localization = make(map[string]*locale.LocaleConfig, len(cfg.AvailableLanguages))
- for _, lang := range cfg.AvailableLanguages {
- localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize a locale: %w", err)
- }
- supplements.Localization[lang.Name] = localeCfg
+ return nil, fmt.Errorf("fail to initialize blog client: type %s: %w", cfg.BlogPages.Storage.Type, err)
}
supplements.MarkdownRenderer = goldmark.New(
@@ -200,7 +189,7 @@ func NewRouter(cfg *config.Config) (*Router, error) {
}
supplements.Mailer, err = mailer.NewMailer(supplements.DB, cfg.Mail.ClientHost, cfg.Mail.MailHost,
- cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), supplements.Localization)
+ cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt))
if err != nil {
return nil, fmt.Errorf("fail to initialize mailer: %w", err)
}
@@ -314,13 +303,12 @@ func (r *Router) InitRoutes() (err error) {
}
defaultMap := fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "Path": trimmedPath,
- "QueryString": queryString,
- "CanonicalEndpoint": r.canonicalEndpoint,
- "StaticStorageBaseUrl": r.supplements.StaticStorage.BaseUrl,
- "ThumbnailBaseUrl": r.supplements.PhotoStorage.Thumbnail320p.BaseUrl,
+ "Lang": lang,
+ "Path": trimmedPath,
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ "StaticStorage": r.supplements.StaticStorage,
+ "PhotoStorage": r.supplements.PhotoStorage,
}
statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap)
@@ -467,13 +455,12 @@ func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error {
}
valueMap := fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "Path": trimmedPath,
- "QueryString": queryString,
- "CanonicalEndpoint": r.canonicalEndpoint,
- "StaticStorageBaseUrl": r.supplements.StaticStorage.BaseUrl,
- "ThumbnailBaseUrl": r.supplements.PhotoStorage.Thumbnail320p.BaseUrl,
+ "Lang": lang,
+ "Path": trimmedPath,
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ "StaticStorage": r.supplements.StaticStorage,
+ "PhotoStorage": r.supplements.PhotoStorage,
}
var err error
@@ -576,11 +563,12 @@ func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error {
cacheKey := ""
trimmedPath := strings.Trim(path, "/")
+ requestQuery := string(c.Request().URI().QueryString())
switch route.ToCache() {
case ByUrlOnly:
cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath)
case ByUrlAndQuery:
- cacheKey = fmt.Sprintf("%s.%s.%s.%s", method, part, trimmedPath, queryString)
+ cacheKey = fmt.Sprintf("%s.%s.%s.%s.%s", method, part, trimmedPath, queryString, requestQuery)
}
if route.ToCache() != Disabled {
@@ -592,13 +580,12 @@ func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error {
var statusCode int
defaultMap := fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": queryString,
- "CanonicalEndpoint": r.canonicalEndpoint,
- "StaticStorageBaseUrl": r.supplements.StaticStorage.BaseUrl,
- "ThumbnailBaseUrl": r.supplements.PhotoStorage.Thumbnail320p.BaseUrl,
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ "StaticStorage": r.supplements.StaticStorage,
+ "PhotoStorage": r.supplements.PhotoStorage,
}
switch part {
diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go
index c9da9df..f90489f 100644
--- a/internal/tailwind/transformer.go
+++ b/internal/tailwind/transformer.go
@@ -45,7 +45,7 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader,
node.SetAttribute([]byte("class"), []byte("text-base/[2] font-gentium tracking-[.0125rem]"))
case *ast.Blockquote:
- node.SetAttribute([]byte("class"), []byte("border-l-2 border-main-medium bg-paper bg-background-dark p-1 mb-2 italic font-thin"))
+ node.SetAttribute([]byte("class"), []byte("border-l-[0.125rem] border-main-medium bg-paper bg-background-dark p-1 mb-2 italic font-thin"))
case *ast.CodeSpan:
node.SetAttribute([]byte("class"), []byte("bg-paper bg-background-dark"))
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
index 9d14e15..ff1214a 100644
--- a/internal/templatemanager/templatemanager.go
+++ b/internal/templatemanager/templatemanager.go
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "github.com/SayaAndy/saya-today-web/l10n"
)
type TemplateManager struct {
@@ -38,6 +40,10 @@ var templateFuncMap = template.FuncMap{
"fdiv": func(a, b int) float64 {
return float64(a) / float64(b)
},
+ "l": func(path ...any) any {
+ return l10n.T.GetPath(path...)
+ },
+ "join": strings.Join,
}
func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) {
diff --git a/locale/localization.en.yaml b/l10n/en.yaml
index 9571aca..9571aca 100644
--- a/locale/localization.en.yaml
+++ b/l10n/en.yaml
diff --git a/locale/localization.ru.yaml b/l10n/ru.yaml
index 1818998..1818998 100644
--- a/locale/localization.ru.yaml
+++ b/l10n/ru.yaml
diff --git a/l10n/translator-hook.go b/l10n/translator-hook.go
new file mode 100644
index 0000000..ff5837c
--- /dev/null
+++ b/l10n/translator-hook.go
@@ -0,0 +1,39 @@
+package l10n
+
+var hooks map[string]*TranslatorHook
+
+type TranslatorHook struct {
+ t *Translator
+ prefix string
+}
+
+func NewTranslatorHook(prefix string) *TranslatorHook {
+ if _, ok := hooks[prefix]; !ok {
+ hooks[prefix] = &TranslatorHook{T, prefix}
+ }
+ return hooks[prefix]
+}
+
+func (h *TranslatorHook) Set(key string, val any, toOverwrite bool) (alreadySet bool, err error) {
+ return h.t.Set(h.prefix+key, val, toOverwrite)
+}
+
+func (h *TranslatorHook) SetPath(val any, toOverwrite bool, path ...any) (alreadySet bool, err error) {
+ return h.t.SetPath(val, toOverwrite, append([]any{h.prefix}, path...)...)
+}
+
+func (h *TranslatorHook) Get(key string) any {
+ return h.t.Get(h.prefix + "." + key)
+}
+
+func (h *TranslatorHook) GetPath(path ...any) any {
+ return h.t.GetPath(append([]any{h.prefix}, path...)...)
+}
+
+func (h *TranslatorHook) GetDefault(key string, def any) any {
+ return h.t.GetDefault(h.prefix+"."+key, def)
+}
+
+func (h *TranslatorHook) GetDefaultPath(def any, path ...any) any {
+ return h.t.GetDefaultPath(def, append([]any{h.prefix}, path...)...)
+}
diff --git a/l10n/translator.go b/l10n/translator.go
new file mode 100644
index 0000000..4d4b4eb
--- /dev/null
+++ b/l10n/translator.go
@@ -0,0 +1,167 @@
+package l10n
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+var T *Translator
+
+func init() {
+ var err error
+ if T, err = NewTranslator("ru", "en"); err != nil {
+ slog.Error("fail to load localization", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+}
+
+type Translator struct {
+ l map[string]any
+ mu sync.RWMutex
+}
+
+func NewTranslator(locales ...string) (*Translator, error) {
+ t := &Translator{l: make(map[string]any)}
+ errs := make([]error, 0)
+
+ for _, locale := range locales {
+ contentBytes, err := os.ReadFile(fmt.Sprintf("l10n/%s.yaml", locale))
+ if err != nil {
+ errs = append(errs, fmt.Errorf("opening one of files: l10n/%s.yaml: %w", locale, err))
+ continue
+ }
+
+ var content map[string]any
+ if err := yaml.Unmarshal(contentBytes, &content); err != nil {
+ errs = append(errs, fmt.Errorf("parsing one of files: l10n/%s.yaml: %w", locale, err))
+ continue
+ }
+
+ errs = append(errs, t.initMap("."+locale, content)...)
+ }
+
+ return t, errors.Join(errs...)
+}
+
+func (t *Translator) Set(key string, val any, toOverwrite bool) (alreadySet bool, err error) {
+ t.mu.RLock()
+ if _, alreadySet = t.l[key]; !alreadySet || (alreadySet && toOverwrite) {
+ t.mu.RUnlock()
+ return alreadySet, errors.Join(t.setSafe(key, val)...)
+ }
+ t.mu.RUnlock()
+ return alreadySet, nil
+}
+
+func (t *Translator) SetPath(val any, toOverwrite bool, path ...any) (alreadySet bool, err error) {
+ key, err := formatPath(path...)
+ if err != nil {
+ return false, err
+ }
+ return t.Set(key, val, toOverwrite)
+}
+
+func (t *Translator) Get(key string) any {
+ return t.GetDefault(key, key[strings.LastIndex(key, ".")+1:])
+}
+
+func (t *Translator) GetPath(path ...any) any {
+ return t.GetDefaultPath(path[len(path)-1], path...)
+}
+
+func (t *Translator) GetDefault(key string, def any) any {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ if v, ok := t.l[key]; ok {
+ return v
+ }
+ slog.Warn("failed to find localization for a key", slog.String("key", key))
+ return def
+}
+
+func (t *Translator) GetDefaultPath(def any, path ...any) any {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ key, err := formatPath(path...)
+ if err != nil {
+ slog.Error("get localized value by path: path is invalid", slog.String("error", err.Error()))
+ return def
+ }
+ return t.GetDefault(key, def)
+}
+
+func (t *Translator) initSlice(parent string, level []any) []error {
+ errs := make([]error, 0)
+ for i, v := range level {
+ newParent := parent + fmt.Sprintf("[%d]", i)
+ t.setSafe(newParent, v)
+ }
+ return errs
+}
+
+func (t *Translator) initMap(parent string, level map[string]any) []error {
+ errs := make([]error, 0)
+ for k, v := range level {
+ key, err := formatPath(k)
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+
+ errs = append(errs, t.setSafe(parent+key, v)...)
+ }
+ return errs
+}
+
+func (t *Translator) setSafe(k string, v any) []error {
+ errs := make([]error, 0)
+ t.mu.Lock()
+ switch vv := v.(type) {
+ case int:
+ t.l[k] = vv
+ t.mu.Unlock()
+ case float64:
+ t.l[k] = vv
+ t.mu.Unlock()
+ case string:
+ t.l[k] = vv
+ t.mu.Unlock()
+ case []any:
+ t.mu.Unlock()
+ errs = append(errs, t.initSlice(k, vv)...)
+ case map[string]any:
+ t.mu.Unlock()
+ errs = append(errs, t.initMap(k, vv)...)
+ default:
+ t.mu.Unlock()
+ errs = append(errs, fmt.Errorf("invalid value type: %s, %v", k, vv))
+ }
+ return errs
+}
+
+func formatPath(path ...any) (string, error) {
+ var key string
+ for _, part := range path {
+ switch k := part.(type) {
+ case int:
+ key += fmt.Sprintf("[%d]", k)
+ case string:
+ key += "." + strings.ReplaceAll(k, ".", "\\.")
+ case []any:
+ add, err := formatPath(k...)
+ if err != nil {
+ return "", fmt.Errorf("while formatting nested slice: %w", err)
+ }
+ key += add
+ default:
+ return "", fmt.Errorf("invalid key part: after %s follows %v", key, k)
+ }
+ }
+ return key, nil
+}
diff --git a/locale/localization.go b/locale/localization.go
deleted file mode 100644
index 87b8ea1..0000000
--- a/locale/localization.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package locale
-
-import (
- "os"
-
- "github.com/go-playground/validator/v10"
- "gopkg.in/yaml.v3"
-)
-
-type LocaleConfig struct {
- TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"`
- BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"`
- GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"`
- HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"`
- UnsubscribePage UnsubscribePageConfig `yaml:"UnsubscribePage" json:"UnsubscribePage"`
- Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"`
- Mail MailConfig `yaml:"Mail" json:"Mail"`
- UserProfile UserProfileConfig `yaml:"UserProfile" json:"UserProfile"`
-}
-
-type BlogSearchConfig struct {
- Header string `yaml:"Header" json:"Header"`
- Description string `yaml:"Description" json:"Description"`
- TagsHeader string `yaml:"TagsHeader" json:"TagsHeader"`
- OrderByHeader string `yaml:"OrderByHeader" json:"OrderByHeader"`
- TitleOrdered string `yaml:"TitleOrdered" json:"TitleOrdered"`
- ActionDateOrdered string `yaml:"ActionDateOrdered" json:"ActionDateOrdered"`
- PublicationDateOrdered string `yaml:"PublicationDateOrdered" json:"PublicationDateOrdered"`
- ChooseAllTags string `yaml:"ChooseAllTags" json:"ChooseAllTags"`
-}
-
-type GlobalMapConfig struct {
- Header string `yaml:"Header" json:"Header"`
- Description string `yaml:"Description" json:"Description"`
-}
-
-type HomePageConfig struct {
- Header string `yaml:"Header" json:"Header"`
- Contacts string `yaml:"Contacts" json:"Contacts"`
- DidYouKnowThat string `yaml:"DidYouKnowThat" json:"DidYouKnowThat"`
- SidebarDescription string `yaml:"SidebarDescription" json:"SidebarDescription"`
- BackDescription string `yaml:"BackDescription" json:"BackDescription"`
- HomePageDescription string `yaml:"HomePageDescription" json:"HomePageDescription"`
- BlogSearchDescription string `yaml:"BlogSearchDescription" json:"BlogSearchDescription"`
- MarkerMapDescription string `yaml:"MarkerMapDescription" json:"MarkerMapDescription"`
- ThemeSwitchDescription string `yaml:"ThemeSwitchDescription" json:"ThemeSwitchDescription"`
- AccountDescription string `yaml:"AccountDescription" json:"AccountDescription"`
- Hymn1 string `yaml:"Hymn1" json:"Hymn1"`
- Hymn2 string `yaml:"Hymn2" json:"Hymn2"`
- Hymn3 string `yaml:"Hymn3" json:"Hymn3"`
- Hymn4 string `yaml:"Hymn4" json:"Hymn4"`
-}
-
-type UnsubscribePageConfig struct {
- Header string `yaml:"Header" json:"Header"`
- UnsetCode string `yaml:"UnsetCode" json:"UnsetCode"`
- InvalidCode string `yaml:"InvalidCode" json:"InvalidCode"`
- OnServerError string `yaml:"OnServerError" json:"OnServerError"`
- Success string `yaml:"Success" json:"Success"`
-}
-
-type MetadataConfig struct {
- Action string `yaml:"Action" json:"Action"`
- Published string `yaml:"Published" json:"Published"`
-}
-
-type MailConfig struct {
- UnsubscribeFooter string `yaml:"UnsubscribeFooter" json:"UnsubscribeFooter"`
- VerifyEmail VerifyEmailConfig `yaml:"VerifyEmail" json:"VerifyEmail"`
- NewPost NewPostConfig `yaml:"NewPost" json:"NewPost"`
-}
-
-type VerifyEmailConfig struct {
- Subject string `yaml:"Subject" json:"Subject"`
- Welcome string `yaml:"Welcome" json:"Welcome"`
- Intro string `yaml:"Intro" json:"Intro"`
- GotoLink string `yaml:"GotoLink" json:"GotoLink"`
- InputCode string `yaml:"InputCode" json:"InputCode"`
- IfRandom string `yaml:"IfRandom" json:"IfRandom"`
-}
-
-type NewPostConfig struct {
- Subject string `yaml:"Subject" json:"Subject"`
- CapturedOn string `yaml:"CapturedOn" json:"CapturedOn"`
- Intro string `yaml:"Intro" json:"Intro"`
-}
-
-type UserProfileConfig struct {
- Header string `yaml:"Header" json:"Header"`
- Description string `yaml:"Description" json:"Description"`
- EmailHeader string `yaml:"EmailHeader" json:"EmailHeader"`
- VerificationCodeHeader string `yaml:"VerificationCodeHeader" json:"VerificationCodeHeader"`
- TypeEmail string `yaml:"TypeEmail" json:"TypeEmail"`
- SendCodeButton string `yaml:"SendCodeButton" json:"SendCodeButton"`
- VerifyButton string `yaml:"VerifyButton" json:"VerifyButton"`
- VerificationCodeSent string `yaml:"VerificationCodeSent" json:"VerificationCodeSent"`
- DelayTilVerification string `yaml:"DelayTilVerification" json:"DelayTilVerification"`
- FailedEmailRender string `yaml:"FailedEmailRender" json:"FailedEmailRender"`
- VerificationCodeSendingError string `yaml:"VerificationCodeSendingError" json:"VerificationCodeSendingError"`
- EmailAlreadyValidated string `yaml:"EmailAlreadyValidated" json:"EmailAlreadyValidated"`
- EmailTaken string `yaml:"EmailTaken" json:"EmailTaken"`
- EmailEmpty string `yaml:"EmailEmpty" json:"EmailEmpty"`
- VerificationSuccess string `yaml:"VerificationSuccess" json:"VerificationSuccess"`
- VerificationFailed string `yaml:"VerificationFailed" json:"VerificationFailed"`
- VerificationEmpty string `yaml:"VerificationEmpty" json:"VerificationEmpty"`
- SubscriptionHeader string `yaml:"SubscriptionHeader" json:"SubscriptionHeader"`
- TagsNone string `yaml:"TagsNone" json:"TagsNone"`
- TagsAll string `yaml:"TagsAll" json:"TagsAll"`
- TagsSpecific string `yaml:"TagsSpecific" json:"TagsSpecific"`
- TagDoesNotExist string `yaml:"TagDoesNotExist" json:"TagDoesNotExist"`
- TagAlreadyAdded string `yaml:"TagAlreadyAdded" json:"TagAlreadyAdded"`
- SaveButton string `yaml:"SaveButton" json:"SaveButton"`
- SubscribeInvalidType string `yaml:"SubscribeInvalidType" json:"SubscribeInvalidType"`
- FailedToSubscribe string `yaml:"FailedToSubscribe" json:"FailedToSubscribe"`
- SubscribedSuccessfully string `yaml:"SubscribedSuccessfully" json:"SubscribedSuccessfully"`
- RefreshPage string `yaml:"RefreshPage" json:"RefreshPage"`
-}
-
-func LoadConfig(path string, config *LocaleConfig) error {
- fileBytes, err := os.ReadFile(path)
- if err != nil {
- return err
- }
-
- if err = yaml.Unmarshal(fileBytes, config); err != nil {
- return err
- }
-
- return nil
-}
-
-func InitConfig(path string) (*LocaleConfig, error) {
- config := &LocaleConfig{}
- if err := LoadConfig(path, config); err != nil {
- return nil, err
- }
-
- validate := validator.New(validator.WithRequiredStructEnabled())
- if err := validate.Struct(config); err != nil {
- return nil, err
- }
-
- return config, nil
-}
diff --git a/static/input.css b/static/input.css
index 8b71614..e0c5c81 100644
--- a/static/input.css
+++ b/static/input.css
@@ -22,30 +22,40 @@
--color-main-dark-hard: var(--color-main-dark-hard);
--color-main-dark-medium: var(--color-main-dark-medium);
--color-main-dark-soft: var(--color-main-dark-soft);
- --shadow-elevation-flat: 0px 1px 1px 0px rgba(0, 0, 0, 0.2);
+ --shadow-elevation-flat: 0px 0.0625rem 0.0625rem 0px rgba(0, 0, 0, 0.2);
+ --inset-shadow-elevation-flat: inset 0px 0.0625rem 0.125rem 0px rgba(0, 0, 0, 0.15);
--shadow-elevation-0: 0px 0px 0px 0px rgba(0, 0, 0, 0.2), 0px 0px 0px 0px rgba(0, 0, 0, 0.14), 0px 0px 0px 0px rgba(0, 0, 0, 0.12);
- --shadow-elevation-1: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12);
- --shadow-elevation-2: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
- --shadow-elevation-3: 0px 3px 3px -2px rgba(0, 0, 0, 0.2), 0px 3px 4px 0px rgba(0, 0, 0, 0.14), 0px 1px 8px 0px rgba(0, 0, 0, 0.12);
- --shadow-elevation-4: 0px 2px 4px -1px rgba(0, 0, 0, 0.2), 0px 4px 5px 0px rgba(0, 0, 0, 0.14), 0px 1px 10px 0px rgba(0, 0, 0, 0.12);
- --shadow-elevation-5: 0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 5px 8px 0px rgba(0, 0, 0, 0.14), 0px 1px 14px 0px rgba(0, 0, 0, 0.12);
- --shadow-elevation-6: 0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12);
- --shadow-elevation-7: 0px 4px 5px -2px rgba(0, 0, 0, 0.2), 0px 7px 10px 1px rgba(0, 0, 0, 0.14), 0px 2px 16px 1px rgba(0, 0, 0, 0.12);
- --shadow-elevation-8: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12);
- --shadow-elevation-9: 0px 5px 6px -3px rgba(0, 0, 0, 0.2), 0px 9px 12px 1px rgba(0, 0, 0, 0.14), 0px 3px 16px 2px rgba(0, 0, 0, 0.12);
- --shadow-elevation-10: 0px 6px 6px -3px rgba(0, 0, 0, 0.2), 0px 10px 14px 1px rgba(0, 0, 0, 0.14), 0px 4px 18px 3px rgba(0, 0, 0, 0.12);
- --shadow-elevation-11: 0px 6px 7px -4px rgba(0, 0, 0, 0.2), 0px 11px 15px 1px rgba(0, 0, 0, 0.14), 0px 4px 20px 3px rgba(0, 0, 0, 0.12);
- --shadow-elevation-12: 0px 7px 8px -4px rgba(0, 0, 0, 0.2), 0px 12px 17px 2px rgba(0, 0, 0, 0.14), 0px 5px 22px 4px rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-0: inset 0px 0px 0px 0px rgba(0, 0, 0, 0);
+ --shadow-elevation-1: 0px 0.125rem 0.0625rem -0.0625rem rgba(0, 0, 0, 0.2), 0px 0.0625rem 0.0625rem 0px rgba(0, 0, 0, 0.14), 0px 0.0625rem 0.1875rem 0px rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-1: inset 0px 0.125rem 0.25rem 0px rgba(0, 0, 0, 0.2);
+ --shadow-elevation-2: 0px 0.1875rem 0.0625rem -0.125rem rgba(0, 0, 0, 0.2), 0px 0.125rem 0.125rem 0px rgba(0, 0, 0, 0.14), 0px 0.0625rem 0.3125rem 0px rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-2: inset 0px 0.1875rem 0.375rem 0px rgba(0, 0, 0, 0.22);
+ --shadow-elevation-3: 0px 0.1875rem 0.1875rem -0.125rem rgba(0, 0, 0, 0.2), 0px 0.1875rem 0.25rem 0px rgba(0, 0, 0, 0.14), 0px 0.0625rem 0.5rem 0px rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-3: inset 0px 0.25rem 0.5rem 0px rgba(0, 0, 0, 0.25);
+ --shadow-elevation-4: 0px 0.125rem 0.25rem -0.0625rem rgba(0, 0, 0, 0.2), 0px 0.25rem 0.3125rem 0px rgba(0, 0, 0, 0.14), 0px 0.0625rem 0.625rem 0px rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-4: inset 0px 0.3125rem 0.625rem 0px rgba(0, 0, 0, 0.27);
+ --shadow-elevation-5: 0px 0.1875rem 0.3125rem -0.0625rem rgba(0, 0, 0, 0.2), 0px 0.3125rem 0.5rem 0px rgba(0, 0, 0, 0.14), 0px 0.0625rem 0.875rem 0px rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-5: inset 0px 0.375rem 0.75rem 0px rgba(0, 0, 0, 0.3);
+ --shadow-elevation-6: 0px 0.1875rem 0.3125rem -0.0625rem rgba(0, 0, 0, 0.2), 0px 0.375rem 0.625rem 0px rgba(0, 0, 0, 0.14), 0px 0.0625rem 1.125rem 0px rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-6: inset 0px 0.4375rem 0.875rem 0px rgba(0, 0, 0, 0.32);
+ --shadow-elevation-7: 0px 0.25rem 0.3125rem -0.125rem rgba(0, 0, 0, 0.2), 0px 0.4375rem 0.625rem 0.0625rem rgba(0, 0, 0, 0.14), 0px 0.125rem 1rem 0.0625rem rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-7: inset 0px 0.5rem 1rem 0px rgba(0, 0, 0, 0.35);
+ --shadow-elevation-8: 0px 0.3125rem 0.3125rem -0.1875rem rgba(0, 0, 0, 0.2), 0px 0.5rem 0.625rem 0.0625rem rgba(0, 0, 0, 0.14), 0px 0.1875rem 0.875rem 0.125rem rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-8: inset 0px 0.5625rem 1.125rem 0px rgba(0, 0, 0, 0.37);
+ --shadow-elevation-9: 0px 0.3125rem 0.375rem -0.1875rem rgba(0, 0, 0, 0.2), 0px 0.5625rem 0.75rem 0.0625rem rgba(0, 0, 0, 0.14), 0px 0.1875rem 1rem 0.125rem rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-9: inset 0px 0.625rem 1.25rem 0px rgba(0, 0, 0, 0.4);
+ --shadow-elevation-10: 0px 0.375rem 0.375rem -0.1875rem rgba(0, 0, 0, 0.2), 0px 0.625rem 0.875rem 0.0625rem rgba(0, 0, 0, 0.14), 0px 0.25rem 1.125rem 0.1875rem rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-10: inset 0px 0.6875rem 1.375rem 0px rgba(0, 0, 0, 0.42);
+ --shadow-elevation-11: 0px 0.375rem 0.4375rem -0.25rem rgba(0, 0, 0, 0.2), 0px 0.6875rem 0.9375rem 0.0625rem rgba(0, 0, 0, 0.14), 0px 0.25rem 1.25rem 0.1875rem rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-11: inset 0px 0.75rem 1.5rem 0px rgba(0, 0, 0, 0.45);
+ --shadow-elevation-12: 0px 0.4375rem 0.5rem -0.25rem rgba(0, 0, 0, 0.2), 0px 0.75rem 1.0625rem 0.125rem rgba(0, 0, 0, 0.14), 0px 0.3125rem 1.375rem 0.25rem rgba(0, 0, 0, 0.12);
+ --inset-shadow-elevation-12: inset 0px 0.8125rem 1.625rem 0px rgba(0, 0, 0, 0.47);
}
html {
- font-size: 12px;
+ font-size: clamp(11px, min(1dvw, 1dvh) + 7.6px, 16px);
--crossed-out: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='30' height='30' patternTransform='scale(1) rotate(65)'><rect x='0' y='0' width='100%' height='100%' fill='%232b2b3100' /><path d='M30 15L22.5 30 15 15 22.5 0zm-15 0L7.5 30 0 15 7.5 0z' stroke-width='0.6' stroke='%23ffffffff' fill='none' /></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)' /></svg>");
--bg-paper: url(data:image/webp;base64,UklGRi4BAABXRUJQVlA4ICIBAABQCQCdASqAAIAAPm02lUmkIqIkIImIgA2JaW3rMFmGDAOKLAoJtP591ByFR74Tun2DP+Lygpfsz5FH7+cKgxi2Islfd03i5AJdXUfU0SDc8SXYAAD++hlzeYAJWQEF8ZdkJdlcA+5btZg0FMGaso8SHoBph0qAOQysElJ5C5SJFNoKIV1J550UyL7Y9Gh0/c/uzLnBmr8lvz7k1WeA81H4S5ViU/wBqa7aM1m8D9rDtrk8cSTqx+xWF6MS8UfZGSsks6CT/igtgv6syFN9uNA3LjnjiCJKYOvFcMkX+jB2578GWVo3nyLaM1QNPYCxn3TiUbnf7U0MdD2NsBpg1+Jd1wu6yW/U+goN+IQS6Nt4RMWltVcZHlxrneKJi0SobO0AAA==);
- --split-left-background: unset;
- --split-left-repeat: no-repeat;
- --split-left-size: cover;
- --split-left-position: left;
--split-right-background: unset;
--split-right-repeat: no-repeat;
--split-right-size: cover;
@@ -77,27 +87,25 @@ body {
scrollbar-color: var(--color-background-light) var(--color-main-soft);
}
-@media screen and (min-width: 640px) and (min-height: 640px) {
- html {
- font-size: 14px;
- }
+body.glightbox-open {
+ height: 100dvh;
}
@media screen and (min-width: 900px) and (min-height: 900px) {
html {
- font-size: 16px;
+ font-size: clamp(16px, min(1dvw, 1dvh) + 7px, 20px);
}
}
@media screen and (min-width: 1400px) and (min-height: 1400px) {
html {
- font-size: 20px;
+ font-size: clamp(20px, min(1dvw, 1dvh) + 6px, 24px);
}
}
@media screen and (min-width: 1920px) and (min-height: 1920px) {
html {
- font-size: 24px;
+ font-size: calc(min(1dvw, 1dvh) + 4.8px);
}
}
@@ -133,6 +141,22 @@ body {
--squares-and-triangles-background-height-coef: 10;
}
+@media (prefers-color-scheme: dark) {
+ [data-theme="olive"] {
+ --color-accent-light: var(--color-lime-600);
+ --color-accent-deep: var(--color-lime-300);
+ --color-main: var(--color-main-light-hard);
+ --color-main-hard: var(--color-main-light-hard);
+ --color-main-medium: var(--color-main-light-medium);
+ --color-main-soft: var(--color-main-light-soft);
+ --color-background: var(--color-main-dark-hard);
+ --color-background-dark: var(--color-main-dark-hard);
+ --color-background-medium: var(--color-main-dark-medium);
+ --color-background-light: var(--color-main-dark-soft);
+ --color-sidebar-stroke: var(--color-main-light-hard);
+ }
+}
+
[data-theme="lettuce"] {
--color-accent-light: var(--color-lime-300);
--color-accent-deep: var(--color-lime-600);
@@ -157,8 +181,8 @@ body {
--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");
+ url("https://cdn.saya.uz/stats/themes/lettuce/linen-fabric.webp"),
+ url("https://cdn.saya.uz/stats/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");
@@ -167,66 +191,76 @@ body {
--squares-and-triangles-background-height-coef: 10;
}
+@media (prefers-color-scheme: dark) {
+ [data-theme="lettuce"] {
+ --color-accent-light: var(--color-lime-600);
+ --color-accent-deep: var(--color-lime-300);
+ --color-main: var(--color-main-light-hard);
+ --color-main-hard: var(--color-main-light-hard);
+ --color-main-medium: var(--color-main-light-medium);
+ --color-main-soft: var(--color-main-light-soft);
+ --color-background: var(--color-main-dark-hard);
+ --color-background-dark: var(--color-main-dark-hard);
+ --color-background-medium: var(--color-main-dark-medium);
+ --color-background-light: var(--color-main-dark-soft);
+ --color-sidebar-stroke: var(--color-main-light-hard);
+ }
+}
+
[data-theme="night"] {
- --color-accent-light: var(--color-indigo-900);
- --color-accent-deep: var(--color-indigo-500);
- --color-main-light-hard: var(--color-sky-100);
- --color-main-light-medium: var(--color-sky-200);
- --color-main-light-soft: var(--color-sky-300);
+ --color-accent-light: var(--color-indigo-500);
+ --color-accent-deep: var(--color-indigo-800);
+ --color-main-light-hard: var(--color-indigo-300);
+ --color-main-light-medium: var(--color-indigo-200);
+ --color-main-light-soft: var(--color-indigo-200);
--color-main-dark-hard: oklch(19.064% 0.01614 284.757);
--color-main-dark-medium: var(--color-main-dark-hard);
--color-main-dark-soft: oklch(25.846% 0.02619 284.414);
- --color-main: var(--color-main-light-hard);
- --color-main-hard: var(--color-main-light-hard);
- --color-main-medium: var(--color-main-light-medium);
- --color-main-soft: var(--color-main-light-soft);
- --color-background: var(--color-main-dark-soft);
- --color-background-dark: var(--color-main-dark-hard);
- --color-background-medium: var(--color-main-dark-medium);
- --color-background-light: var(--color-main-dark-soft);
+ --color-main: var(--color-main-dark-hard);
+ --color-main-hard: var(--color-main-dark-hard);
+ --color-main-medium: var(--color-main-dark-medium);
+ --color-main-soft: var(--color-main-dark-soft);
+ --color-background: var(--color-main-light-soft);
+ --color-background-dark: var(--color-main-light-hard);
+ --color-background-medium: var(--color-main-light-medium);
+ --color-background-light: var(--color-main-light-soft);
--color-sidebar-text: var(--color-main-hard);
- --color-sidebar-stroke: var(--color-main-light-soft);
+ --color-sidebar-stroke: var(--color-main-dark-soft);
--color-secondary: var(--color-main-soft);
--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%
+ linear-gradient(
+ 180deg,
+ #4c51bf 0%,
+ rgba(89, 108, 214, 0.75) 12%,
+ rgba(107, 136, 231, 0.5) 24%,
+ rgba(163, 191, 250, 0) 48%
),
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%
+ 100% 37.5% at 50% 100%,
+ #f687b3 0%,
+ rgba(245, 151, 227, 0.75) 25%,
+ rgba(217, 182, 245, 0.5) 50%,
+ rgba(190, 227, 248, 0) 100%
+ ),
+ linear-gradient(
+ 360deg,
+ #f6ad55 0%,
+ rgba(247, 183, 98, 0.75) 14%,
+ rgba(249, 192, 112, 0.5) 28%,
+ rgba(251, 211, 141, 0) 56%
),
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%
+ 90deg,
+ #bee3f8 0%,
+ #b7e0f7 14.29%,
+ #b1ddf7 28.57%,
+ #aadaf6 42.86%,
+ #a4d7f6 57.14%,
+ #9dd3f5 71.43%,
+ #90cdf4 100%
);
--interlocked-hexagons-background-repeat: no-repeat;
--interlocked-hexagons-background-size: 100dvw 100dvh;
@@ -236,38 +270,108 @@ body {
--squares-and-triangles-background-height-coef: 5;
}
+@media (prefers-color-scheme: dark) {
+ [data-theme="night"] {
+ --color-accent-light: var(--color-indigo-900);
+ --color-accent-deep: var(--color-indigo-500);
+ --color-main: var(--color-main-light-hard);
+ --color-main-hard: var(--color-main-light-hard);
+ --color-main-medium: var(--color-main-light-medium);
+ --color-main-soft: var(--color-main-light-soft);
+ --color-background: var(--color-main-dark-soft);
+ --color-background-dark: var(--color-main-dark-hard);
+ --color-background-medium: var(--color-main-dark-medium);
+ --color-background-light: var(--color-main-dark-soft);
+ --color-sidebar-stroke: var(--color-main-light-soft);
+ --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%
+ );
+ }
+}
+
[data-theme="ram"] {
- --color-accent-light: var(--color-orange-800);
- --color-accent-deep: var(--color-orange-300);
+ --color-accent-light: var(--color-orange-300);
+ --color-accent-deep: var(--color-orange-800);
--color-main-light-hard: oklch(97.95% 0.0443 101.66);
--color-main-light-medium: oklch(92.541% 0.04519 81.305);
--color-main-light-soft: oklch(87.909% 0.04338 76.287);
--color-main-dark-hard: oklch(35.615% 0.01442 71.558);
--color-main-dark-medium: oklch(46.724% 0.01927 64.275);
--color-main-dark-soft: oklch(50.352% 0.02092 64.558);
- --color-main: var(--color-main-light-hard);
- --color-main-hard: var(--color-main-light-hard);
- --color-main-medium: var(--color-main-light-medium);
- --color-main-soft: var(--color-main-light-soft);
- --color-background: var(--color-main-dark-soft);
- --color-background-dark: var(--color-main-dark-hard);
- --color-background-medium: var(--color-main-dark-medium);
- --color-background-light: var(--color-main-dark-soft);
+ --color-main: var(--color-main-dark-hard);
+ --color-main-hard: var(--color-main-dark-hard);
+ --color-main-medium: var(--color-main-dark-medium);
+ --color-main-soft: var(--color-main-dark-soft);
+ --color-background: var(--color-main-light-hard);
+ --color-background-dark: var(--color-main-light-hard);
+ --color-background-medium: var(--color-main-light-medium);
+ --color-background-light: var(--color-main-light-soft);
--color-sidebar-text: var(--color-main-hard);
- --color-sidebar-stroke: var(--color-main-light-soft);
+ --color-sidebar-stroke: var(--color-main-dark-soft);
--color-secondary: var(--color-main-soft);
--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://cdn.saya.uz/stats/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://cdn.saya.uz/stats/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;
}
+@media (prefers-color-scheme: dark) {
+ [data-theme="ram"] {
+ --color-accent-light: var(--color-orange-800);
+ --color-accent-deep: var(--color-orange-300);
+ --color-main: var(--color-main-light-hard);
+ --color-main-hard: var(--color-main-light-hard);
+ --color-main-medium: var(--color-main-light-medium);
+ --color-main-soft: var(--color-main-light-soft);
+ --color-background: var(--color-main-dark-soft);
+ --color-background-dark: var(--color-main-dark-hard);
+ --color-background-medium: var(--color-main-dark-medium);
+ --color-background-light: var(--color-main-dark-soft);
+ --color-sidebar-stroke: var(--color-main-light-soft);
+ }
+}
+
[data-theme="lilac"] {
--color-accent-light: var(--color-indigo-300);
--color-accent-deep: var(--color-indigo-800);
@@ -298,8 +402,24 @@ 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-right-background: url("https://cdn.saya.uz/stats/themes/lilac/split-right-light.webp");
+}
+
+@media (prefers-color-scheme: dark) {
+ [data-theme="lilac"] {
+ --color-accent-light: var(--color-indigo-800);
+ --color-accent-deep: var(--color-indigo-300);
+ --color-main: var(--color-main-light-hard);
+ --color-main-hard: var(--color-main-light-hard);
+ --color-main-medium: var(--color-main-light-medium);
+ --color-main-soft: var(--color-main-light-soft);
+ --color-background: var(--color-main-dark-hard);
+ --color-background-dark: var(--color-main-dark-hard);
+ --color-background-medium: var(--color-main-dark-medium);
+ --color-background-light: var(--color-main-dark-soft);
+ --color-sidebar-stroke: var(--color-main-dark-hard);
+ --split-right-background: url("https://cdn.saya.uz/stats/themes/lilac/split-right-dark.webp");
+ }
}
a,
@@ -314,16 +434,16 @@ a:hover,
}
#map-container {
- color: var(--color-leaflet-text) !important;
+ color: var(--color-main-hard) !important;
}
#map-container a {
- color: var(--color-leaflet-link) !important;
+ color: var(--color-main-medium) !important;
text-decoration: underline;
}
#map-container a:hover {
- color: var(--color-leaflet-link-hover) !important;
+ color: var(--color-main-soft) !important;
text-decoration: underline;
}
@@ -379,13 +499,6 @@ form.crossable.htmx-request input {
background-repeat: var(--squares-and-triangles-background-repeat);
}
-.bg-split-left {
- background-image: var(--split-left-background);
- background-repeat: var(--split-left-repeat);
- background-size: var(--split-left-size);
- background-position: var(--split-left-position);
-}
-
.bg-split-right {
background-image: var(--split-right-background);
background-repeat: var(--split-right-repeat);
@@ -426,14 +539,6 @@ form.crossable.htmx-request input {
calc(var(--squares-and-triangles-background-height-coef) * 1rem);
}
-.inset-shadow {
- box-shadow:
- inset 0dvh 1dvh 1dvh -1dvh rgba(75, 81, 58, 0.5),
- inset 1dvh 0dvh 1dvh -1dvh rgba(75, 81, 58, 0.5),
- inset -1dvh 0dvh 1dvh -1dvh rgba(75, 81, 58, 0.5),
- inset 0dvh -0.5dvh rgba(75, 81, 58, 0.5);
-}
-
.text-stroke-\(--color-sidebar-stroke\) {
-webkit-text-stroke-color: var(--color-sidebar-stroke);
}
@@ -446,7 +551,7 @@ form.crossable.htmx-request input {
position: fixed;
width: 12rem;
height: 12rem;
- box-shadow: 0.25rem 0.25rem 0.25rem var(--color-main-soft);
+ box-shadow: 0px 0.3125rem 0.3125rem -0.1875rem rgba(0, 0, 0, 0.2), 0px 0.5rem 0.625rem 0.0625rem rgba(0, 0, 0, 0.14), 0px 0.1875rem 0.875rem 0.125rem rgba(0, 0, 0, 0.12);
border-radius: 50%;
opacity: 0;
visibility: hidden;
@@ -481,7 +586,7 @@ form.crossable.htmx-request input {
.theme-icon i {
position: absolute;
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
+ filter: drop-shadow(0 0.125rem 0.25rem rgba(0, 0, 0, 0.3));
z-index: 1;
}
@@ -507,7 +612,7 @@ form.crossable.htmx-request input {
box-shadow: 0rem 0rem 1rem var(--color-main-hard);
border: 0.5rem solid
color-mix(in srgb, var(--color-main-soft) 90%, transparent);
- backdrop-filter: blur(2px);
+ backdrop-filter: blur(0.125rem);
top: 35%;
left: 35%;
width: 30%;
@@ -532,6 +637,10 @@ form.crossable.htmx-request input {
transition-timing-function: var(--ease-in-out);
}
+.accent-button {
+ box-shadow: 0px 0.1875rem 0.1875rem -0.125rem rgba(0, 0, 0, 0.2), 0px 0.1875rem 0.25rem 0px rgba(0, 0, 0, 0.14), 0px 0.0625rem 0.5rem 0px rgba(0, 0, 0, 0.12);
+}
+
.accent-button:hover,
.accent-button.active:active:hover {
cursor: pointer;
@@ -545,6 +654,7 @@ form.crossable.htmx-request input {
--tw-gradient-position: from 45deg in oklab;
--tw-gradient-to: color-mix(in oklab, var(--color-accent-dark) 30%, transparent);
--tw-gradient-via: color-mix(in oklab, var(--color-accent-light) 50%, transparent);
+ box-shadow: inset 0px 0.25rem 0.5rem 0px rgba(0, 0, 0, 0.25);
}
.accent-button.active:hover,
@@ -560,7 +670,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(0.125rem 0.125rem 0.125rem rgba(0, 0, 0, 0.3));
transition: transform 0.2s;
}
@@ -616,6 +726,15 @@ form.crossable.htmx-request input {
width: 10rem;
}
+.grid-item picture {
+ mask-image:
+ linear-gradient(to right, #FFFFFF calc(100% - 0.5rem), transparent 100%),
+ linear-gradient(to left, #FFFFFF calc(100% - 0.5rem), transparent 100%),
+ linear-gradient(to top, #FFFFFF calc(100% - 0.5rem), transparent 100%),
+ linear-gradient(to bottom, #FFFFFF calc(100% - 0.5rem), transparent 100%);
+ mask-composite: intersect;
+}
+
.grid-sizer {
width: 10.5rem;
}
@@ -639,6 +758,10 @@ form.crossable.htmx-request input {
opacity: 0;
}
+.grid-item:hover picture {
+ mask-image: none;
+}
+
.grid-item-2x {
width: 20rem;
}
@@ -738,9 +861,29 @@ form.crossable.htmx-request input {
height: 100% !important;
}
-body[data-theme~="night"] .leaflet-tile,
-body[data-theme~="ram"] .leaflet-tile {
- filter: invert(1) hue-rotate(190deg) contrast(1.1) brightness(1.2) !important;
+.leaflet-container .leaflet-control-attribution {
+ background: color-mix(in oklch, var(--color-background-medium) 80%, transparent) !important;
+}
+
+.leaflet-popup-content-wrapper, .leaflet-popup-tip {
+ background: var(--color-background-medium) !important;
+}
+
+.leaflet-bar a {
+ background-color: var(--color-background-medium) !important;
+}
+
+.leaflet-popup-content-wrapper, .leaflet-popup-tip {
+ color: var(--color-main-medium) !important;
+}
+
+.leaflet-marker-icon.marker-cluster {
+ color: var(--color-main-dark-hard) !important;
+}
+
+.leaflet-attribution-flag {
+ visibility: hidden;
+ width: 0;
}
.home-page-heart {
@@ -761,8 +904,8 @@ body[data-theme~="ram"] .leaflet-tile {
}
.ram-frame {
- width: 20vmin;
- height: 20vmin;
+ width: 8rem;
+ height: 8rem;
user-select: none;
-webkit-user-select: none;
}
diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html
index 2d125ae..06ef625 100644
--- a/views/layouts/general-page.html
+++ b/views/layouts/general-page.html
@@ -14,7 +14,7 @@
{{end}}
/>
{{- if eq .Property "og:title" }}
- <title>{{.Content}} // SAYA.UZ</title>
+ <title>{{.Content}}</title>
{{- end }} {{- if eq .Property "og:description" }}
<meta name="description" content="{{.Content}}" />
{{- end }} {{- if eq .Property "og:url" }}
@@ -22,15 +22,23 @@
{{- end }} {{- end }}
<link
rel="stylesheet"
- href="{{ .StaticStorageBaseUrl }}/fonts/fonts.css"
+ href="{{ .StaticStorage.BaseUrl }}/fonts/fonts.css"
/>
<link
rel="stylesheet"
- href="{{ .StaticStorageBaseUrl }}/libs/leaflet/1.9.4/leaflet.css"
+ href="{{ .StaticStorage.BaseUrl }}/libs/leaflet/1.9.4/leaflet.css"
/>
<link
rel="stylesheet"
- href="{{ .StaticStorageBaseUrl }}/libs/glightbox/3.3.0/css/glightbox.min.css"
+ href="{{ .StaticStorage.BaseUrl }}/libs/leaflet.markercluster/1.4.1/MarkerCluster.css"
+ />
+ <link
+ rel="stylesheet"
+ href="{{ .StaticStorage.BaseUrl }}/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css"
+ />
+ <link
+ rel="stylesheet"
+ href="{{ .StaticStorage.BaseUrl }}/libs/glightbox/3.3.0/css/glightbox.min.css"
/>
<link rel="stylesheet" href="/output.css" />
<link
@@ -55,6 +63,11 @@
{{ . }}
</script>
{{- end }}
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/htmx/2.0.6/htmx.min.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/leaflet/1.9.4/leaflet.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/protomaps-leaflet/5.0.0/dist/protomaps-leaflet.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/leaflet.markercluster/1.4.1/leaflet.markercluster.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/glightbox/3.3.0/js/glightbox.min.js"></script>
</head>
<body
@@ -160,8 +173,6 @@
}
</script>
- <script src="{{ .StaticStorageBaseUrl }}/libs/glightbox/3.3.0/js/glightbox.min.js"></script>
-
<script>
function sfc32(a, b, c, d) {
return function () {
@@ -202,18 +213,8 @@
var seed = cyrb128("{{ .Title }}");
var rand = sfc32(seed[0], seed[1], seed[2], seed[3]);
- function calculateVmin(percent) {
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- const smallerDimension = Math.min(
- viewportWidth,
- viewportHeight,
- );
-
- const vminValue = (smallerDimension / 100) * percent;
-
- return vminValue;
+ function calculateRem(remNum) {
+ return remNum * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
const _probe = document.createElement('div');
@@ -308,7 +309,7 @@
theme = document.body.getAttribute("data-theme");
}
- vmin = calculateVmin(1);
+ rem = calculateRem(1);
var clWidth = document.documentElement.clientWidth;
var clHeight = document.documentElement.clientHeight;
@@ -327,11 +328,11 @@
"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;
+ const x = Math.floor(rand() * clWidth - 1.42 * sz); // 1.42 ~ sqrt(2)
+ const y = Math.floor(rand() * clHeight * 0.8);
star.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`;
star.style.animationDuration = `${animDuration}s`;
star.style.fontSize = `${sz}px`;
@@ -342,7 +343,7 @@
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.src = `{{ .StaticStorage.BaseUrl }}/themes/ram/ram-frame${i}.webp`;
frame.classList.add(
"ram-frame",
"theme-decor",
@@ -351,14 +352,14 @@
);
let x, y;
outerLoop: while (true) {
- x = Math.floor(rand() * clWidth - 20 * vmin);
- y = Math.floor(rand() * clHeight - 20 * vmin);
+ x = Math.floor(rand() * clWidth - 22.64 * rem) + 11.32 * rem; // 11.32 ~ 8 * sqrt(2)
+ y = Math.floor(rand() * clHeight - 22.64 * rem) + 11.32 * rem;
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
+ 23 * rem
) {
continue outerLoop;
}
@@ -401,21 +402,28 @@
localStorage.setItem("theme", theme);
document.body.setAttribute("data-theme", theme);
switchThemeDecor(theme);
- if (theme == "ram" || theme == "night") {
+ switchColorScheme(matchMedia('(prefers-color-scheme: dark)').matches);
+ }
+
+ const savedTheme = localStorage.getItem("theme") || "lilac";
+ switchTheme(savedTheme);
+
+ function switchColorScheme(isNightMode) {
+ if (isNightMode) {
makeMultitoneOklch('multitone', 4, '--color-background-dark', '--color-accent-light', '--color-background-light');
} else {
makeMultitoneOklch('multitone', 4, '--color-background-light', '--color-accent-light', '--color-background-dark');
}
}
- const savedTheme = localStorage.getItem("theme") || "lilac";
- switchTheme(savedTheme);
+ const mq = matchMedia('(prefers-color-scheme: dark)');
+ switchColorScheme(mq.matches);
+ mq.addEventListener('change', (e) => switchColorScheme(e.matches));
</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 src="{{ .StaticStorage.BaseUrl }}/libs/htmx/2.0.6/htmx.min.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/imagesloaded/5.0.0/imagesloaded.pkgd.min.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/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
@@ -620,15 +628,15 @@
class="flex flex-1 min-h-0 z-1 bg-paper bg-background shadow-elevation-3 @container/body"
>
<div
- class="bg-paper bg-background-dark border-r-2 border-dashed border-main-hard"
+ class="bg-paper bg-background-dark border-r-[0.25rem] 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 md:pr-4"
+ class="max-h-full max-w-full flex flex-col grow bg-paper bg-background-dark relative md:pr-4"
>
<header
id="general-page-header"
- class="max-xs:hidden [@media(max-height:32rem)]:hidden flex flex-col ml-8 basis-8"
+ class="max-xs:hidden [@media(max-height:32rem)]:hidden pt-4 flex flex-col ml-8 basis-8"
>{{ .RenderedHeader }}</header>
<div
id="general-page-header-trigger"
@@ -639,12 +647,12 @@
></div>
<hr
- class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-4 border-dotted border-main-hard mb-2"
+ class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-[0.5rem] border-dotted border-main-hard mb-2"
/>
<main
id="general-page-body"
- class="relative flex-1 overflow-y-auto @container-size"
+ class="relative flex-1 overflow-y-auto @container-size inset-shadow-elevation-8"
>{{ .RenderedBody }}</main>
<div
id="general-page-body-trigger"
@@ -652,6 +660,7 @@
hx-trigger="popstate from:window"
hx-target="#general-page-body"
hx-swap="innerHTML"
+ hx-include="#general-page-body .htmx-exit-trigger"
></div>
<footer
@@ -665,14 +674,10 @@
hx-target="#general-page-footer"
hx-swap="innerHTML"
></div>
-
- <hr
- class="border-t-4 border-dotted border-main-hard mb-2"
- />
</div>
<div
- class="bg-paper bg-background-dark shadow-2xl border-l-2 border-dotted border-main-hard"
+ class="bg-paper bg-background-dark shadow-2xl border-l-[0.25rem] border-dotted border-main-hard"
></div>
</div>
</div>
@@ -699,17 +704,6 @@
}
});
- function calculateVmax(percent) {
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- const largerDimension = Math.max(viewportWidth, viewportHeight);
-
- const vmaxValue = (largerDimension / 100) * percent;
-
- return vmaxValue;
- }
-
function positionThemeIcons() {
const sliceAngle = 360 / themeIcons.length;
diff --git a/views/messages/new-post.html b/views/messages/new-post.html
index 98f0b52..4e586e2 100644
--- a/views/messages/new-post.html
+++ b/views/messages/new-post.html
@@ -1,15 +1,15 @@
{{ define "body" }}
-<p>{{ .L.Mail.NewPost.Intro }}</p>
+<p>{{ l $.Lang "Mail" "NewPost" "Intro" }}</p>
<table>
<tr>
- <td rowspan="3"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}"><img src="{{ printf .ThumbnailBaseUrl .Post.Metadata.Thumbnail }}"></a></td>
+ <td rowspan="3"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}"><img src="{{ printf .PhotoStorage.Thumbnail320p.BaseUrl .Post.Metadata.Thumbnail }}"></a></td>
<td class="darkened" style="font-size: 24px"><a style="color: #273de1 !important;" href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}">{{ .Post.Metadata.Title }}</a></td>
</tr>
<tr>
<td class="darkened" style="font-size: 16px">{{ .Post.Metadata.ShortDescription }}</td>
</tr>
<tr>
- <td class="darkened" style="font-size: 16px">{{ .L.Mail.NewPost.CapturedOn }} {{ .Post.Metadata.ActionDate }}</td>
+ <td class="darkened" style="font-size: 16px">{{ l $.Lang "Mail" "NewPost" "CapturedOn" }} {{ .Post.Metadata.ActionDate }}</td>
</tr>
</table>
{{ end }}
diff --git a/views/messages/verify-email.html b/views/messages/verify-email.html
index dd6ef33..e6fff76 100644
--- a/views/messages/verify-email.html
+++ b/views/messages/verify-email.html
@@ -1,10 +1,10 @@
{{ define "body" }}
-<p>{{ .L.Mail.VerifyEmail.Welcome }}</p>
-<p>{{ .L.Mail.VerifyEmail.Intro }}</p>
+<p>{{ l $.Lang "Mail" "VerifyEmail" "Welcome" }}</p>
+<p>{{ l $.Lang "Mail" "VerifyEmail" "Intro" }}</p>
<ol>
- <li>{{ .L.Mail.VerifyEmail.GotoLink }} <a href="https://{{ .ClientHost }}/{{ .Lang }}/user?email_code={{ .VerificationCode }}" class="outlier">&gt;&gt;&gt;</a></li>
- <li>{{ .L.Mail.VerifyEmail.InputCode }} <span class="outlier">{{ .VerificationCode }}</span><span></li>
+ <li>{{ l $.Lang "Mail" "VerifyEmail" "GotoLink" }} <a href="https://{{ .ClientHost }}/{{ .Lang }}/user?email_code={{ .VerificationCode }}" class="outlier">&gt;&gt;&gt;</a></li>
+ <li>{{ l $.Lang "Mail" "VerifyEmail" "InputCode" }} <span class="outlier">{{ .VerificationCode }}</span><span></li>
</ol>
-<p>{{ .L.Mail.VerifyEmail.IfRandom }}</p>
+<p>{{ l $.Lang "Mail" "VerifyEmail" "IfRandom" }}</p>
<a href="https://{{ .ClientHost }}" class="outlier">{{ .ClientHost }}</a>
{{ end }} \ No newline at end of file
diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html
index 4a7d9bf..ca8f578 100644
--- a/views/pages/blog-catalogue.html
+++ b/views/pages/blog-catalogue.html
@@ -45,63 +45,63 @@
</symbol>
</svg>
<div class="flex md:flex-row flex-col text-base h-full">
- <div class="relative flex shrink-0 w-full max-h-[30%] md:min-w-1/10 md:max-w-1/4 md:max-h-full md:h-full md:w-fit">
- <div class="absolute w-full h-full inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
+ <div class="relative flex shrink-0 w-full max-h-[30%] md:min-w-1/10 md:max-w-1/4 md:max-h-full md:h-full md:w-fit shadow-elevation-6">
+ <div class="absolute w-full h-full pointer-events-none z-20"></div>
<form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML"
- class="tags-list relative flex flex-col w-full bg-paper bg-background-light">
- <div class="flex flex-col overflow-y-auto">
+ class="tags-list relative flex flex-col w-full">
+ <div class="flex flex-col bg-paper bg-background-light overflow-y-auto inset-shadow-elevation-6">
<fieldset>
- <legend class="font-bold w-full text-center">{{ .L.BlogSearch.OrderByHeader }}</legend>
+ <legend class="font-bold w-full text-center">{{ l $.Lang "BlogSearch" "OrderByHeader" }}</legend>
<div class="md:flex flex-col grid grid-cols-3 grid-rows-2 grid-flow-col">
- <div>
+ <div class="m-1">
<label class="flex justify-start gap-1 items-center">
<input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" class="bg-accent-light border-transparent text-accent-deep focus:border-transparent focus:bg-accent-light focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}>
- {{ .L.BlogSearch.TitleOrdered }}
+ {{ l $.Lang "BlogSearch" "TitleOrdered" }}
<svg viewBox="0 0 24 24" class="h-8"><use href="#icon-arrange-letter-asc"/></svg>
</label>
</div>
- <div>
+ <div class="m-1">
<label class="flex justify-start gap-1 items-center">
<input type="radio" id="sortTitleDesc" name="sort" value="titleDesc" class="bg-accent-light border-transparent text-accent-deep focus:border-transparent focus:bg-accent-light focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep" {{ if eq .QuerySort "titleDesc" }}checked{{ end }}>
- {{ .L.BlogSearch.TitleOrdered }}
+ {{ l $.Lang "BlogSearch" "TitleOrdered" }}
<svg viewBox="0 0 24 24" class="h-8"><use href="#icon-arrange-letter-desc"/></svg>
</label>
</div>
- <div>
+ <div class="m-1">
<label class="flex justify-start gap-1 items-center">
<input type="radio" id="sortActionDateAsc" name="sort" value="actionDateAsc" class="bg-accent-light border-transparent text-accent-deep focus:border-transparent focus:bg-accent-light focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep" {{ if eq .QuerySort "actionDateAsc" }}checked{{ end }}>
- {{ .L.BlogSearch.ActionDateOrdered }}
+ {{ l $.Lang "BlogSearch" "ActionDateOrdered" }}
<svg viewBox="0 0 24 24" class="h-8"><use href="#icon-arrange-number-asc"/></svg>
</label>
</div>
- <div>
+ <div class="m-1">
<label class="flex justify-start gap-1 items-center">
<input type="radio" id="sortActionDateDesc" name="sort" value="actionDateDesc" class="bg-accent-light border-transparent text-accent-deep focus:border-transparent focus:bg-accent-light focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep" {{ if eq .QuerySort "actionDateDesc" }}checked{{ end }}>
- {{ .L.BlogSearch.ActionDateOrdered }}
+ {{ l $.Lang "BlogSearch" "ActionDateOrdered" }}
<svg viewBox="0 0 24 24" class="h-8"><use href="#icon-arrange-number-desc"/></svg>
</label>
</div>
- <div>
+ <div class="m-1">
<label class="flex justify-start gap-1 items-center">
<input type="radio" id="sortPublicationDateAsc" name="sort" value="publicationDateAsc" class="bg-accent-light border-transparent text-accent-deep focus:border-transparent focus:bg-accent-light focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep" {{ if eq .QuerySort "publicationDateAsc" }}checked{{ end }}>
- {{ .L.BlogSearch.PublicationDateOrdered }}
+ {{ l $.Lang "BlogSearch" "PublicationDateOrdered" }}
<svg viewBox="0 0 24 24" class="h-8"><use href="#icon-arrange-number-asc"/></svg>
</label>
</div>
- <div>
+ <div class="m-1">
<label class="flex justify-start gap-1 items-center">
<input type="radio" id="sortPublicationDateDesc" name="sort" value="publicationDateDesc" class="bg-accent-light border-transparent text-accent-deep focus:border-transparent focus:bg-accent-light focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep" {{ if eq .QuerySort "publicationDateDesc" }}checked{{ end }}>
- {{ .L.BlogSearch.PublicationDateOrdered }}
+ {{ l $.Lang "BlogSearch" "PublicationDateOrdered" }}
<svg viewBox="0 0 24 24" class="h-8"><use href="#icon-arrange-number-desc"/></svg>
</label>
</div>
</div>
</fieldset>
<fieldset class="mt-1">
- <legend class="font-bold w-full text-center">{{ .L.BlogSearch.TagsHeader }}</legend>
+ <legend class="font-bold w-full text-center">{{ l $.Lang "BlogSearch" "TagsHeader" }}</legend>
<div class="md:flex flex-col grid grid-cols-3 xs:grid-cols-4 sm:grid-cols-5 grid-flow-row-dense">
<div class="m-1">
- <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();" class="bg-accent-light rounded border-transparent text-accent-deep focus:border-transparent focus:bg-background-dark focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep"> {{ .L.BlogSearch.ChooseAllTags }}</label>
+ <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();" class="bg-accent-light rounded border-transparent text-accent-deep focus:border-transparent focus:bg-background-dark focus:ring-1 focus:ring-offset-2 focus:ring-accent-deep"> {{ l $.Lang "BlogSearch" "ChooseAllTags" }}</label>
</div>
{{- range .Tags }}
<div class="m-1">
@@ -111,24 +111,21 @@
</div>
</fieldset>
</div>
- <hr class="border-t-2 border-dotted border-main-hard my-0.5">
- <div class="flex flex-row bg-paper bg-background-dark p-0.5 rounded-1 h-12">
- <button type="button" onclick="defaultSearchParams(); cleanHrefParams();" class="flex flex-1 justify-center text-main-hard bg-paper bg-accent-light cursor-pointer hover:bg-accent-deep active:text-background-dark transition-colors duration-500">
- <svg viewBox="0 0 24 24"><use href="#icon-camera-settings-flip"/></svg>
+ <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 mx-auto w-24">
+ <div class="flex flex-row p-0.5 rounded-1 h-12">
+ <button type="button" onclick="defaultSearchParams(); cleanHrefParams();" class="accent-button grow h-full">
+ <svg class="h-full mx-auto" viewBox="0 0 24 24"><use href="#icon-camera-settings-flip"/></svg>
</button>
- <div class="w-1 bg-paper bg-background-light grow-0"></div>
- <button type="submit" onclick="setHrefParams();" class="flex flex-1 justify-center text-main-hard bg-paper bg-accent-light cursor-pointer hover:bg-accent-deep active:text-background-dark transition-colors duration-500">
- <svg viewBox="0 0 24 24"><use href="#icon-search-magnifier"/></svg>
+ <button type="submit" onclick="setHrefParams();" class="accent-button grow h-full">
+ <svg class="h-full mx-auto" viewBox="0 0 24 24"><use href="#icon-search-magnifier"/></svg>
</button>
</div>
</form>
</div>
- <div class="w-[0.5dvh] border-r-2 border-dotted border-main-hard bg-paper bg-background-dark"></div>
-
<div class="relative flex grow h-full">
- <div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
- <div hx-get="/api/v1/blog-search" hx-vals='js:{lang: "{{ .Lang }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load" hx-include=".tags-list"
+ <div class="absolute w-full h-full inset-0 inset-shadow-elevation-6 pointer-events-none z-20"></div>
+ <div hx-get="/api/v1/blog-search" hx-vals='js:{lang: "{{ .Lang }}", tz: clientTimeZone, highlight: "{{ .PreviousBlogPage }}"}' hx-target="this" hx-swap="innerHTML" hx-trigger="load" hx-include=".tags-list"
class="blog-cards relative grow flex flex-col overflow-y-auto bg-paper bg-background-light pr-2">
Loading...
</div>
diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html
index dd716fb..3d3c623 100644
--- a/views/pages/blog-page.html
+++ b/views/pages/blog-page.html
@@ -1,83 +1,50 @@
{{ define "body" }}
-<div class="sticky top-0 left-0 w-full h-[100cqb] -mb-[100cqb] inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
-<article class="relative bg-paper bg-background-light flex flex-col px-8 py-4 overflow-y-auto">
- <div class="flex flex-col sm:flex-row h-[30cqb] sm:h-[15cqb]">
- <div class="grow shrink-2 flex-1 overflow-y-auto -ml-8 -mt-4 {{if .Medley}}max-sm:-mr-8{{else}}-mr-8{{end}} z-1 shadow-elevation-2">
- <div class="multitone min-h-full" style="--multitone-bg: url({{ printf $.ThumbnailBaseUrl .Thumbnail }});">
+<input class="htmx-exit-trigger" type="hidden" name="codename" value="{{ .Codename }}">
+<div class="relative bg-paper bg-background-light flex flex-col py-4 overflow-y-auto">
+ <div class="flex flex-col sm:flex-row h-[30cqb] sm:h-[15cqb] shadow-elevation-6">
+ <div class="grow shrink-2 flex-1 overflow-y-auto -mt-4 z-1 shadow-elevation-3">
+ <div class="multitone min-h-full" style="--multitone-bg: url({{ printf $.PhotoStorage.Thumbnail560p.BaseUrl .Thumbnail }});">
<div class="ml-8 py-4">
<h1 class="max-xs:flex flex-row content-center [@media(max-height:32rem)]:block hidden font-gentium text-2xl font-bold italic text-main-hard tracking-[.0125rem] mb-1">
<div hx-get="/api/v1/like" hx-target="this" hx-swap="outerHTML" hx-trigger="load"></div>
{{ .Title }}
</h1>
<p class="block grow text-lg font-gentium text-secondary">
- <b>{{ .L.Metadata.Published }}</b>:
+ <b>{{ l $.Lang "Metadata" "Published" }}</b>:
<time datetime="{{ .PublishedDate }}" hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load">
{{ .PublishedDate }}
</time>
</p>
- <p class="block grow text-lg font-gentium text-secondary"><b>{{ .L.Metadata.Action }}</b>: {{ .ActionDate }}</p>
+ <p class="block grow text-lg font-gentium text-secondary"><b>{{ l $.Lang "Metadata" "Action" }}</b>: {{ .ActionDate }}</p>
<p class="block grow text-md font-gentium font-thin text-main-hard">{{ .ShortDescription }}</p>
</div>
</div>
</div>
- <hr class="block sm:hidden border-t-3 bg-paper bg-background-dark border-dotted border-main-hard w-full">
+ <hr class="block sm:hidden border-t-[0.1875rem] bg-paper bg-background-dark border-dotted border-main-hard w-full">
{{- if .Medley }}
- <div class="overflow-y-auto grow-2 shrink-0 flex-1 bg-paper bg-background-dark shadow-elevation-2 -mr-8 sm:-mt-4 max-sm:-ml-8 z-2">
- <h2 class="text-2xl font-gentium font-extrabold text-main-hard tracking-[.0125rem]">
- {{ .Medley }}
+ <div class="overflow-y-auto grow-2 shrink-0 flex-1 bg-paper bg-background-dark shadow-elevation-3 sm:-mt-4 z-2">
+ <h2 class="text-2xl font-gentium font-extrabold text-main-hard tracking-[.0125rem] ml-2 mb-2 border-b-[0.25rem] border-dotted w-fit">
+ {{ l .Lang "Medleys" .Medley }}
</h2>
- <hr class="block border-t-2 my-1 border-dotted border-main-hard w-full">
- <div hx-get="/api/v1/blog-search" hx-vals='js:{lang: "{{ .Lang }}", tz: clientTimeZone, medley: "{{ .Medley }}", sort: "medley", hideTags: true, hidePublishedTime: true}' hx-target="this" hx-swap="innerHTML" hx-trigger="load"
+ <div hx-get="/api/v1/blog-search" hx-vals='js:{lang: "{{ .Lang }}", tz: clientTimeZone, medley: "{{ .Medley }}", sort: "medley", hideTags: true, hidePublishedTime: true, highlight: "{{ .Codename }}"}' hx-target="this" hx-swap="innerHTML" hx-trigger="load"
class="grow flex flex-col pr-2">
Loading...
</div>
</div>
{{- end }}
</div>
- <hr class="block border-t-3 mb-2 border-dotted border-main-hard w-full ml-auto mr-auto">
- {{ .ParsedMarkdown }}
+ <hr class="block border-t-[0.375rem] w-24 mx-auto my-4 border-dotted border-main-hard">
+ <article class="px-8 flex flex-col">
+ {{ .ParsedMarkdown }}
+ </article>
{{- if .MapLocationX }}
- <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto">
- <div id="map-container" class="relative p-1 flex-none ml-auto mr-auto w-[80%] lg:w-[60%] h-[30dvh] md:h-[40dvh]"></div>
- <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto">
+ <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 mx-auto">
+ <div id="map-outer-container" class="relative p-1 flex-none mx-auto w-[80%] lg:w-[60%] h-[30dvh] md:h-[40dvh]"
+ hx-get="/api/v1/map" hx-vals='{"lang": "{{ .Lang }}", "codename": "{{ .Codename }}", "zoom": 8}' hx-target="this" hx-swap="innerHTML" hx-trigger="load">
+ </div>
+ <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 mx-auto">
<script>
- function initLocationMap(id, x, y, relativeErrorMeters = 0) {
- map = L.map(id).setView([x, y], 8);
-
- L.tileLayer('https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.png', {
- maxZoom: 20,
- attribution: '&copy; <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a>, &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>',
- }).addTo(map);
-
- if (relativeErrorMeters != 0) {
- var circle = L.circle([x, y], {
- color: '#8a9d8a',
- fillColor: '#8a9d8a',
- fillOpacity: 0.15,
- radius: relativeErrorMeters
- }).addTo(map);
- }
-
- var marker = L.marker([x, y], {
- title: '{{ .Title }}',
- }).addTo(map)
- .bindPopup('<span><b>{{ .Title }}</b><br><a href="https://www.openstreetmap.org/#map=13/{{ .MapLocationX }}/{{ .MapLocationY }}">{{ .MapLocationX }}, {{ .MapLocationY }}</a></span>')
- .openPopup();
- }
-
- if (map) {
- map.remove();
- map = null;
- }
-
- setTimeout(() => initLocationMap(
- 'map-container',
- {{ .MapLocationX }},
- {{ .MapLocationY }},
- {{ .MapLocationAreaMeters }}
- ), 1000);
-
var lightbox = GLightbox({
selector: '.glightbox',
moreLength: 0
diff --git a/views/pages/global-map.html b/views/pages/global-map.html
index a6dc006..4d1853f 100644
--- a/views/pages/global-map.html
+++ b/views/pages/global-map.html
@@ -3,23 +3,23 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <meta name="description" content="{{ .L.GlobalMap.Description }}" />
- <title>{{ .L.GlobalMap.Header }} // SAYA.UZ</title>
- <meta property="og:title" content="{{ .L.GlobalMap.Header }}" />
+ <meta name="description" content="{{ l $.Lang "GlobalMap" "Description" }}" />
+ <title>{{ l $.Lang "GlobalMap" "Header" }} // SAYA.UZ</title>
+ <meta property="og:title" content="{{ l $.Lang "GlobalMap" "Header" }}" />
<meta
property="og:description"
- content="{{ .L.GlobalMap.Description }}"
+ content="{{ l $.Lang "GlobalMap" "Description" }}"
/>
<meta
property="og:url"
content="{{ .CanonicalEndpoint }}/{{ .Lang }}/map"
/>
<meta property="og:type" content="site" />
- <title>{{ .L.GlobalMap.Header }} // SAYA.UZ</title>
+ <title>{{ l $.Lang "GlobalMap" "Header" }} // SAYA.UZ</title>
<link rel="canonical" href="{{ .CanonicalEndpoint }}/{{ .Lang }}/map" />
<link
rel="stylesheet"
- href="{{ .StaticStorageBaseUrl }}/fonts/fonts.css"
+ href="{{ .StaticStorage.BaseUrl }}/fonts/fonts.css"
/>
{{ block "header" . }}{{ end }}
<link rel="stylesheet" href="/output.css" />
@@ -42,16 +42,20 @@
/>
<link
rel="stylesheet"
- href="{{ .StaticStorageBaseUrl }}/libs/leaflet/1.9.4/leaflet.css"
+ href="{{ .StaticStorage.BaseUrl }}/libs/leaflet/1.9.4/leaflet.css"
/>
<link
rel="stylesheet"
- href="{{ .StaticStorageBaseUrl }}/libs/leaflet.markercluster/1.4.1/MarkerCluster.css"
+ href="{{ .StaticStorage.BaseUrl }}/libs/leaflet.markercluster/1.4.1/MarkerCluster.css"
/>
<link
rel="stylesheet"
- href="{{ .StaticStorageBaseUrl }}/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css"
+ href="{{ .StaticStorage.BaseUrl }}/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css"
/>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/htmx/2.0.6/htmx.min.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/leaflet/1.9.4/leaflet.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/protomaps-leaflet/5.0.0/dist/protomaps-leaflet.js"></script>
+ <script src="{{ .StaticStorage.BaseUrl }}/libs/leaflet.markercluster/1.4.1/leaflet.markercluster.js"></script>
</head>
<body
@@ -216,21 +220,14 @@
</div>
</nav>
- <div
- id="map-container"
- class="bg-paper bg-background-dark flex z-10 w-full h-full"
- ></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/leaflet.markercluster/1.4.1/leaflet.markercluster.js"></script>
+ <div id="map-outer-container" class="bg-paper bg-background-dark flex z-10 w-full h-full"
+ hx-get="/api/v1/map" hx-vals='{"lang": "{{ .Lang }}", "codename": "{{ .Codename }}", "zoom": 4, "zoomPosition": "topright"}' hx-target="this" hx-swap="innerHTML" hx-trigger="load">
+ </div>
<script>
const themeWheel = document.getElementById("theme-wheel");
const themeButton = document.getElementById("sidebar-theme-button");
const themeIcons = document.querySelectorAll(".theme-icon");
- const themeWheelRadius = convertRemToPixels(6);
- const THUMB_BASE = "{{ .ThumbnailBaseUrl }}";
themeIcons.forEach((icon) => {
if (icon.getAttribute("data-theme") == savedTheme) {
@@ -251,6 +248,7 @@
function positionThemeIcons() {
const sliceAngle = 360 / themeIcons.length;
+ const themeWheelRadius = convertRemToPixels(6);
themeIcons.forEach((icon, index) => {
const startAngle = index * sliceAngle;
@@ -294,6 +292,7 @@
const buttonRect = themeButton.getBoundingClientRect();
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
+ const themeWheelRadius = convertRemToPixels(6);
const margin = 0;
@@ -367,95 +366,5 @@
}
});
</script>
-
- <script>
- var map;
- var markers = L.markerClusterGroup();
-
- function createCustomIcon(color, borderColor = "var(--color-main-soft)") {
- const svgIcon = `
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
- <path d="M12 1C7.5 1 4 4.5 4 9c0 6.5 8 14 8 14s8-7.5 8-14c0-4.5-3.5-8-8-8zm0 11c-1.5 0-3-1.5-3-3s1.5-3 3-3 3 1.5 3 3-1.5 3-3 3z"
- fill="${color}"
- stroke="${borderColor}"
- stroke-width="1"
- stroke-linejoin="round"/>
- </svg>
- `;
-
- return L.divIcon({
- html: svgIcon,
- className: 'custom-svg-marker',
- iconSize: [32, 32],
- iconAnchor: [16, 32],
- popupAnchor: [0, -32]
- });
- }
-
- function initLocationMap() {
- map = L.map('map-container').setView([{{ .MapLocationLat }}, {{ .MapLocationLong }}], 4);
-
- L.tileLayer('https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.png', {
- maxZoom: 20,
- attribution: '&copy; <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a>, &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>',
- zoomControl: false
- }).addTo(map);
-
- L.control.zoom({
- position: 'topright'
- }).addTo(map);
-
- map.addLayer(markers);
- }
-
- function markerToHsl(str, newCoef) {
- let hash = 0;
- for (let i = 0; i < str.length / 2; i++) {
- hash = str.charCodeAt(i) + ((hash << 5) - hash);
- hash = str.charCodeAt(str.length - i - 1) + ((hash << 5) - hash);
- hash %= 360;
- }
-
- return [hash, (20 + newCoef * 80).toFixed(0), (20 + newCoef * 75).toFixed(0)];
- }
-
- function mapAddMarker(x, y, relativeErrorMeters = 0, title = "", link = "", thumbnail = "", newCoef = 1) {
- const [h, s, l] = markerToHsl(title, newCoef);
- const color = `hsl(${h} ${s}% ${l}%)`;
-
- if (relativeErrorMeters != 0) {
- var circle = L.circle([x, y], {
- color: color,
- fillColor: color,
- fillOpacity: 0.15,
- radius: relativeErrorMeters
- }).addTo(map);
- }
-
- var marker = L.marker([x, y], {
- icon: createCustomIcon(color),
- title: title,
- }).bindPopup(`
- <div class="flex flex-row justify-center justify-items-center">
- <img onclick="location.href='${link}';" class="cursor-pointer object-cover rounded-[10%] select-none w-12 h-12 mr-1" src="${THUMB_BASE.replace('%s', thumbnail)}">
- <span><b><a href="${link}">${title}</a></b><br><a href="https://www.openstreetmap.org/#map=13/${x}/${y}">${x}, ${y}</a></span>
- </div>`);
-
- markers.addLayer(marker);
- }
-
- document.addEventListener('DOMContentLoaded', () => {
- initLocationMap();
- {{- range .MapMarkers }}
- mapAddMarker({{ .Lat }}, {{ .Long }}, {{ .AccuracyMeters }}, {{ .Title }}, {{ .PageLink }}, {{ .Thumbnail }}, {{ fdiv .Index (len $.MapMarkers) }});
- {{- end }}
- });
-
- window.addEventListener('resize', function() {
- setTimeout(() => {
- map.invalidateSize();
- }, 100);
- });
- </script>
</body>
</html>
diff --git a/views/pages/home-page.html b/views/pages/home-page.html
index 6e026aa..d494a08 100644
--- a/views/pages/home-page.html
+++ b/views/pages/home-page.html
@@ -17,24 +17,23 @@
<path d="M4.59 15.35a39.94 39.94 0 0 0 -2.94 3.25 0.35 0.35 0 1 0 0.53 0.46c0.59 -0.6 1.69 -1.51 2.89 -2.58 0.63 -0.57 1.29 -1.17 1.9 -1.79s1.42 -1.52 2 -2.21 -0.14 -1 -0.82 -0.39c-0.52 0.46 -1.08 0.94 -1.67 1.47s-1.28 1.19 -1.89 1.79Z" fill="var(--color-accent-deep)" stroke-width="1"></path>
</g></symbol>
</svg>
-<div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
<div class="relative bg-paper bg-background-light font-gentium flex flex-col sm:flex-row w-full h-full overflow-y-auto text-base/loose tracking-wide z-10">
<div class="sm:flex-3/5 justify-center content-start p-8">
<div class="flex flex-col justify-start items-start bg-paper bg-background-dark mb-8 shadow-[0.4rem_0.4rem_0.4rem_black]">
<p class="text-3xl/loose sm:text-4xl/loose lg:text-5xl/loose tracking-widest uppercase w-full font-m-plus font-thin italic text-left
- border-l-4 border-solid border-background-dark
- bg-linear-to-r from-background-dark to-background-light">{{ .L.HomePage.Hymn1 }}</p>
- <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
- <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn2 }}</p>
- <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn3 }}</p>
- <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn4 }}</p>
+ border-l-[0.25rem] border-solid border-background-dark
+ bg-linear-to-r from-background-dark to-background-light">{{ l $.Lang "HomePage" "Hymn1" }}</p>
+ <hr class="w-full border-t-[0.125rem] border-dotted border-main-hard mb-1">
+ <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ l $.Lang "HomePage" "Hymn2" }}</p>
+ <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ l $.Lang "HomePage" "Hymn3" }}</p>
+ <p class="text-xl/loose sm:text-2xl/loose lg:text-3xl/loose -indent-8 ml-12 p-1">{{ l $.Lang "HomePage" "Hymn4" }}</p>
</div>
<div class="flex flex-col justify-center items-center">
<p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-m-plus font-thin italic text-left
- border-l-1.5 border-solid border-background-dark
- bg-linear-to-r from-background-dark to-background-light">{{ .L.HomePage.DidYouKnowThat }}</p>
- <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
+ border-l-[0.125rem] border-solid border-background-dark
+ bg-linear-to-r from-background-dark to-background-light">{{ l $.Lang "HomePage" "DidYouKnowThat" }}</p>
+ <hr class="w-full border-t-[0.125rem] border-dotted border-main-hard mb-1">
<ul class="w-full text-left list-[circle]">
<li class="ml-4 mb-1">{{ index .FunFacts 0 }}</li>
<li class="ml-4 mb-1">{{ index .FunFacts 1 }}</li>
@@ -46,9 +45,9 @@
<div class="sm:flex-2/5 justify-center content-start p-8">
<div class="flex flex-col justify-center items-start mb-4">
<p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-m-plus font-thin italic text-left
- border-l-1.5 border-solid border-background-dark
- bg-linear-to-r from-background-dark to-background-light">{{ .L.HomePage.Contacts }}</p>
- <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
+ border-l-[0.125rem] border-solid border-background-dark
+ bg-linear-to-r from-background-dark to-background-light">{{ l $.Lang "HomePage" "Contacts" }}</p>
+ <hr class="w-full border-t-[0.125rem] border-dotted border-main-hard mb-1">
<a href="https://t.me/EarlInisMona">
<svg class="w-4 h-4 mr-1 inline" viewBox="0 0 24 24"><use href="#icon-telegram"/></svg>
<span class="font-bold">Telegram:</span>
@@ -74,7 +73,7 @@
<div class="home-page-heart z-50">♡</div>
{{- end }}
<img src="{{ .GifUrl }}"
- class="home-page-gif z-49 -rotate-12 max-w-[70%] max-h-[70%] border-4 border-background-dark border-inset">
+ class="home-page-gif z-49 -rotate-12 max-w-[70%] max-h-[70%] border-[0.25rem] border-background-dark border-inset">
</div>
<script>
@@ -121,23 +120,23 @@
<div class="flex flex-col justify-center items-center">
<p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-m-plus font-thin italic text-left
- border-l-1.5 border-solid border-color-background-dark
- bg-linear-to-r from-background-dark to-background-light">{{ .L.HomePage.SidebarDescription }}</p>
- <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
+ border-l-[0.125rem] border-solid border-color-background-dark
+ bg-linear-to-r from-background-dark to-background-light">{{ l $.Lang "HomePage" "SidebarDescription" }}</p>
+ <hr class="w-full border-t-[0.125rem] border-dotted border-main-hard mb-1">
<div class="w-full grid grid-cols-[1fr_4fr] content-center items-center
[&>*:nth-child(4n+2)]:bg-background-medium/50 [&>*:nth-child(4n+3)]:bg-background-medium/50" style="container-type: inline-size;">
<svg viewBox="0 0 24 24" class="h-full w-[15cqi] context-center text-main-hard"><use href="#icon-back"/></svg>
- <p class="p-1 w-[85cqi]">{{ .L.HomePage.BackDescription }}</p>
+ <p class="p-1 w-[85cqi]">{{ l $.Lang "HomePage" "BackDescription" }}</p>
<svg viewBox="0 0 24 24" class="h-full w-[15cqi] context-center text-main-hard"><use href="#icon-logo"/></svg>
- <p class="p-1 w-[85cqi]">{{ .L.HomePage.HomePageDescription }}</p>
+ <p class="p-1 w-[85cqi]">{{ l $.Lang "HomePage" "HomePageDescription" }}</p>
<svg viewBox="0 0 24 24" class="h-full w-[15cqi] context-center text-main-hard"><use href="#icon-newspaper"/></svg>
- <p class="p-1 w-[85cqi]">{{ .L.HomePage.BlogSearchDescription }}</p>
+ <p class="p-1 w-[85cqi]">{{ l $.Lang "HomePage" "BlogSearchDescription" }}</p>
<svg viewBox="0 0 24 24" class="h-full w-[15cqi] context-center text-main-hard"><use href="#icon-map"/></svg>
- <p class="p-1 w-[85cqi]">{{ .L.HomePage.MarkerMapDescription }}</p>
+ <p class="p-1 w-[85cqi]">{{ l $.Lang "HomePage" "MarkerMapDescription" }}</p>
<svg viewBox="0 0 24 24" class="h-full w-[15cqi] context-center text-main-hard"><use href="#icon-palette"/></svg>
- <p class="p-1 w-[85cqi]">{{ .L.HomePage.ThemeSwitchDescription }}</p>
+ <p class="p-1 w-[85cqi]">{{ l $.Lang "HomePage" "ThemeSwitchDescription" }}</p>
<svg viewBox="0 0 24 24" class="h-full w-[15cqi] context-center text-main-hard"><use href="#icon-account"/></svg>
- <p class="p-1 w-[85cqi]">{{ .L.HomePage.AccountDescription }}</p>
+ <p class="p-1 w-[85cqi]">{{ l $.Lang "HomePage" "AccountDescription" }}</p>
</div>
</div>
</div>
diff --git a/views/pages/language-pick.html b/views/pages/language-pick.html
index f4119e4..9576a65 100644
--- a/views/pages/language-pick.html
+++ b/views/pages/language-pick.html
@@ -1,7 +1,7 @@
{{ define "body" }}
<div class="flex flex-col justify-center text-3xl h-full">
{{- range .AvailableLanguages }}
- <hr class="border-t-2 border-dotted border-main-hard">
+ <hr class="border-t-[0.125rem] border-dotted border-main-hard">
<div class="nth-[2n+1_of_div]:bg-background-medium/50 nth-[2n_of_div]:bg-background-light/50 p-2.5 flex flex-row justify-center justify-items-center">
<a href="/{{ .Name }}" class="w-12 h-12 basis-12 shrink-0 mr-4">
<img onclick="return changeUrl('{{ .Name }}');" class="w-full h-full aspect-square cursor-pointer object-cover rounded-md select-none" src="{{ .Flag }}">
@@ -11,6 +11,6 @@
</a>
</div>
{{- end }}
- <hr class="border-t-2 border-dotted border-main-hard">
+ <hr class="border-t-[0.125rem] border-dotted border-main-hard">
</div>
{{ end }}
diff --git a/views/pages/unsubscribe-page.html b/views/pages/unsubscribe-page.html
index 9a78488..12ff1f0 100644
--- a/views/pages/unsubscribe-page.html
+++ b/views/pages/unsubscribe-page.html
@@ -21,7 +21,7 @@
href="/favicon.ico"
type="image/x-icon"
/>
- <title>{{ .L.UnsubscribePage.Header }} // SAYA.UZ</title>
+ <title>{{ l $.Lang "UnsubscribePage" "Header" }} // SAYA.UZ</title>
<style>
.bg-paper {
background-image: url(data:image/webp;base64,UklGRi4BAABXRUJQVlA4ICIBAABQCQCdASqAAIAAPm02lUmkIqIkIImIgA2JaW3rMFmGDAOKLAoJtP591ByFR74Tun2DP+Lygpfsz5FH7+cKgxi2Islfd03i5AJdXUfU0SDc8SXYAAD++hlzeYAJWQEF8ZdkJdlcA+5btZg0FMGaso8SHoBph0qAOQysElJ5C5SJFNoKIV1J550UyL7Y9Gh0/c/uzLnBmr8lvz7k1WeA81H4S5ViU/wBqa7aM1m8D9rDtrk8cSTqx+xWF6MS8UfZGSsks6CT/igtgv6syFN9uNA3LjnjiCJKYOvFcMkX+jB2578GWVo3nyLaM1QNPYCxn3TiUbnf7U0MdD2NsBpg1+Jd1wu6yW/U+goN+IQS6Nt4RMWltVcZHlxrneKJi0SobO0AAA==);
diff --git a/views/pages/user-page.html b/views/pages/user-page.html
index 0dd006a..a94dcb3 100644
--- a/views/pages/user-page.html
+++ b/views/pages/user-page.html
@@ -1,74 +1,74 @@
{{ define "body" }}
-<div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div>
+<div class="absolute w-full h-full inset-0 inset-shadow-elevation-6 pointer-events-none z-20"></div>
<div class="relative bg-paper bg-background-light flex flex-col px-8 py-4">
<form id="email-form" class="w-full mb-2 flex flex-col relative crossable"
hx-get="/api/v1/email/is-in-verification" hx-target="#email-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this">
<div class="flex flex-row w-full mb-4 z-21">
<div class="flex flex-col w-full sm:flex-3/5 grow-0">
<label class="block text-main-medium font-bold mb-1 z-22">
- {{ .L.UserProfile.EmailHeader }}
+ {{ l $.Lang "UserProfile" "EmailHeader" }}
</label>
- <input class="bg-background-medium appearance-none border-background-medium rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-background-light focus:text-main-hard z-22"
- id="email-input" name="email" type="text" value="{{ .Email }}" placeholder="{{ .L.UserProfile.TypeEmail }}" maxlength="64"
+ <input class="bg-background-medium appearance-none border-background-medium rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-elevation-6 focus:outline-none focus:bg-background-light focus:text-main-hard z-22"
+ id="email-input" name="email" type="text" value="{{ .Email }}" placeholder="{{ l $.Lang "UserProfile" "TypeEmail" }}" maxlength="64"
data-validated-email="{{ .Email }}" onchange="checkIfValidated(this);">
</div>
</div>
<button class="w-max mx-auto bg-main-hard hover:bg-main-medium active:bg-main-soft active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-background-light font-bold py-2 px-4 rounded z-22"
hx-post="/api/v1/email/send-verification-code" hx-target="#email-message" hx-swap="outerHTML" hx-indicator="#email-form">
- {{ .L.UserProfile.SendCodeButton }}
+ {{ l $.Lang "UserProfile" "SendCodeButton" }}
</button>
</form>
- <div id="email-message" class="bg-paper bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
+ <div id="email-message" class="bg-paper bg-background-dark text-main-soft z-26 w-full inset-shadow-elevation-6 py-2 px-4 text-center"></div>
- <hr class="my-2 border-t-4 border-dotted border-main-hard">
+ <hr class="my-2 border-t-[0.25rem] border-dotted border-main-hard">
<form id="email-verification-form" class="w-full mb-2 flex flex-col relative hidden crossable"
{{ if .EmailCode }}hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"{{ end }}>
<div class="flex flex-row w-full mb-4 z-21">
<div class="flex flex-col w-full sm:flex-2/5 grow-0">
<label class="block text-main-medium font-bold ml-1 z-22">
- {{ .L.UserProfile.VerificationCodeHeader }}
+ {{ l $.Lang "UserProfile" "VerificationCodeHeader" }}
</label>
- <input class="bg-background-medium appearance-none border-background-medium rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-background-light focus:text-main-hard z-22"
+ <input class="bg-background-medium appearance-none border-background-medium rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-elevation-6 focus:outline-none focus:bg-background-light focus:text-main-hard z-22"
name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16" autocapitalize="characters">
</div>
</div>
<button class="w-max mx-auto bg-main-hard hover:bg-main-medium active:bg-main-soft active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-background-light font-bold py-2 px-4 rounded z-22"
hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-indicator="#email-verification-form">
- {{ .L.UserProfile.VerifyButton }}
+ {{ l $.Lang "UserProfile" "VerifyButton" }}
</button>
</form>
- <div id="verification-message" class="bg-paper bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
+ <div id="verification-message" class="bg-paper bg-background-dark text-main-soft z-26 w-full inset-shadow-elevation-6 py-2 px-4 text-center"></div>
</div>
-<hr class="my-4 border-t-4 border-dotted border-main-hard">
+<hr class="my-4 border-t-[0.25rem] border-dotted border-main-hard">
-<div class="bg-paper bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4">
+<div class="bg-paper bg-background-light flex flex-col inset-shadow-elevation-6 px-8 py-4">
<form id="subs-form" class="w-full mb-2 flex flex-col relative crossable">
<div class="flex flex-col w-full mb-4 z-21">
<label class="block text-main-medium font-bold mb-1 z-22">
- {{ .L.UserProfile.SubscriptionHeader }}
+ {{ l $.Lang "UserProfile" "SubscriptionHeader" }}
</label>
<div class="flex flex-col sm:flex-row w-full">
<div class="m-1 w-1/3">
- <label><input class="mr-1" type="radio" id="tagsNone" name="tags" value="none" {{ if eq .TagsPicked "none" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsNone }}</label>
+ <label><input class="mr-1" type="radio" id="tagsNone" name="tags" value="none" {{ if eq .TagsPicked "none" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ l $.Lang "UserProfile" "TagsNone" }}</label>
</div>
<div class="m-1 w-1/3">
- <label><input class="mr-1" type="radio" id="tagsAll" name="tags" value="all" {{ if eq .TagsPicked "all" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsAll }}</label>
+ <label><input class="mr-1" type="radio" id="tagsAll" name="tags" value="all" {{ if eq .TagsPicked "all" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ l $.Lang "UserProfile" "TagsAll" }}</label>
</div>
<div class="m-1 w-1/3">
- <label><input class="mr-1" type="radio" id="tagsSpecific" name="tags" value="specific" {{ if eq .TagsPicked "specific" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsSpecific }}</label>
+ <label><input class="mr-1" type="radio" id="tagsSpecific" name="tags" value="specific" {{ if eq .TagsPicked "specific" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ l $.Lang "UserProfile" "TagsSpecific" }}</label>
</div>
</div>
<div id="pick-tags-form" class="flex flex-col w-full relative crossable">
- <input type="text" class="bg-background-medium appearance-none border-background-medium rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-background-light focus:text-main-hard z-22"
+ <input type="text" class="bg-background-medium appearance-none border-background-medium rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-elevation-6 focus:outline-none focus:bg-background-light focus:text-main-hard z-22"
list="existing-tags" onchange="addTag(this.value);" onkeydown="if (event.keyCode === 13) {addTag(this.value); this.value=''; return false;}">
<datalist id="existing-tags">
{{- range .ExistingTags }}
<option value="{{ .Name }}">
{{- end }}
</datalist>
- <div id="tags-picked-list" class="bg-paper bg-background-dark flex flex-wrap inset-shadow-[0_0_0.4rem_black] p-2 m-4 w-full">
+ <div id="tags-picked-list" class="bg-paper bg-background-dark flex flex-wrap inset-shadow-elevation-6 p-2 m-4 w-full">
{{- range .TagsPickedList }}
<div id="tagPicked{{ . }}" class="bg-paper bg-main-hard text-background-light m-2 flex flex-row">
<p class="py-1 px-2">{{ . }}</p>
@@ -80,11 +80,11 @@
</div>
<button class="w-max mx-auto bg-color-main-hard hover:bg-main-medium active:bg-color-main-soft active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-background-light font-bold py-2 px-4 rounded z-22"
hx-put="/api/v1/subs" hx-target="#subs-message" hx-swap="outerHTML" hx-indicator="#subs-form">
- {{ .L.UserProfile.SaveButton }}
+ {{ l $.Lang "UserProfile" "SaveButton" }}
</button>
</div>
</form>
- <div id="subs-message" class="bg-paper bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
+ <div id="subs-message" class="bg-paper bg-background-dark text-main-soft z-26 w-full inset-shadow-elevation-6 py-2 px-4 text-center"></div>
</div>
<script>
@@ -168,7 +168,7 @@ function addTag(name) {
found = [...existingTags].find(opt => opt.value===name);
if (!found) {
subsMessage.classList.remove("hidden");
- subsMessage.innerHTML = "{{ .L.UserProfile.TagDoesNotExist }}".replace("{}", name);
+ subsMessage.innerHTML = "{{ l $.Lang "UserProfile" "TagDoesNotExist" }}".replace("{}", name);
return;
}
@@ -177,7 +177,7 @@ function addTag(name) {
found = [...subscribedTags].find(opt => opt.value===name);
if (found) {
subsMessage.classList.remove("hidden");
- subsMessage.innerHTML = "{{ .L.UserProfile.TagAlreadyAdded }}".replace("{}", name);
+ subsMessage.innerHTML = "{{ l $.Lang "UserProfile" "TagAlreadyAdded" }}".replace("{}", name);
return;
}
diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html
index 54b2914..50ceae9 100644
--- a/views/partials/blog-page-like-button.html
+++ b/views/partials/blog-page-like-button.html
@@ -1,5 +1,5 @@
<button hx-put="/api/v1/like" hx-vals='{"like": {{ not .Liked }}}' hx-target="this" hx-swap="outerHTML" hx-trigger="click"
- class="accent-button rounded-xl flex flex-row shrink-0 w-fit h-full mx-2 my-auto{{ if .Liked }} active{{ end }}">
+ class="accent-button rounded-xl relative shrink-0 w-fit aspect-square mr-4 p-2 my-auto{{ if .Liked }} active{{ end }}">
{{ if .Liked }}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="Like-Hand-1--Streamline-Freehand" class="h-10">
<desc>
@@ -12,7 +12,7 @@
<path d="M13.94 4.33a2.57 2.57 0 0 1 1.43 0.88 4.52 4.52 0 0 1 1 1.54 0.57 0.57 0 0 0 0.56 0.37 0.57 0.57 0 0 0 0.53 -0.41 2.81 2.81 0 0 1 1 -1.41 6.9 6.9 0 0 1 1.69 -1 0.49 0.49 0 0 0 0.31 -0.43 0.5 0.5 0 0 0 -0.24 -0.47 8.18 8.18 0 0 1 -2.26 -1.47 2.39 2.39 0 0 1 -0.71 -1.46 0.34 0.34 0 0 0 -0.34 -0.34 0.35 0.35 0 0 0 -0.34 0.35 0.29 0.29 0 0 0 -0.27 0.23 4.41 4.41 0 0 1 -1 1.63 3.53 3.53 0 0 1 -1.48 1 0.47 0.47 0 0 0 -0.32 0.5 0.48 0.48 0 0 0 0.44 0.49Zm2 -1.47a5.53 5.53 0 0 0 0.82 -1.44 3.34 3.34 0 0 0 0.59 1 6.91 6.91 0 0 0 1.53 1.3 5.93 5.93 0 0 0 -1.48 1 6.91 6.91 0 0 0 -0.51 0.57 4.8 4.8 0 0 0 -0.7 -0.85 4 4 0 0 0 -1 -0.74 5.76 5.76 0 0 0 0.71 -0.84Z" fill="var(--color-accent-deep)" fill-rule="evenodd" stroke-width="1"></path>
</g>
</svg>
- <div class="ml-auto bg-accent-light rounded-full w-6 h-6 text-main">{{ .LikedCount }}</div>
+ <div class="absolute left-10 -top-2 bg-accent-light rounded-full w-6 h-6 text-main! font-m-plus! font-normal! tracking-normal! not-italic! text-lg!">{{ .LikedCount }}</div>
{{ else }}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="Like-Hand--Streamline-Freehand" class="h-10">
<desc>
@@ -23,6 +23,6 @@
<path d="M3.53 9.34a2.22 2.22 0 0 0 -1.24 -0.88 5.37 5.37 0 0 0 -2 -0.26 0.3 0.3 0 0 0 -0.29 0.31 0.3 0.3 0 0 0 0.32 0.29 4.36 4.36 0 0 1 1.44 0.25 1.74 1.74 0 0 1 1 0.69A9.16 9.16 0 0 1 3 11.8c0 0.64 0.06 1.38 0.09 2.14 0 1.07 0.08 2.2 0.11 3.22s0.06 2.19 0 2.84c0 0.23 -0.12 0.45 -0.07 0.51A2.7 2.7 0 0 1 2 21a3.34 3.34 0 0 1 -1.49 0 0.34 0.34 0 0 0 -0.22 0.65 4.22 4.22 0 0 0 1.82 0.13 3.37 3.37 0 0 0 1.79 -0.69 1.82 1.82 0 0 0 0.26 -0.82 37.13 37.13 0 0 0 0.14 -5.13c0 -1 -0.1 -2 -0.19 -2.83a12.48 12.48 0 0 0 -0.58 -2.97Z" fill="var(--color-accent-deep)" fill-rule="evenodd" stroke-width="1"></path>
</g>
</svg>
- <div class="ml-auto bg-accent-deep rounded-full w-6 h-6 text-background">{{ .LikedCount }}</div>
+ <div class="absolute left-10 -top-2 bg-accent-deep rounded-full w-6 h-6 text-background! font-m-plus! font-normal! tracking-normal! not-italic! text-lg!">{{ .LikedCount }}</div>
{{ end }}
</button>
diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html
index 3eebd58..6466262 100644
--- a/views/partials/catalogue-blog-cards.html
+++ b/views/partials/catalogue-blog-cards.html
@@ -1,8 +1,8 @@
{{- range .BlogPages }}
-<hr class="first-of-type:hidden border-t-2 border-dotted border-main-hard">
-<div {{ if .ToHighlight }}id="blog-page-highlighted"{{ end }} class="w-full flex flex-row items-stretch {{ if .ToHighlight }}bg-accent-light bg-paper{{ end }}">
- <div class="relative min-h-24 flex flex-col w-28 overflow-hidden shrink-0 mask-r-from-70% mask-r-to-100%">
- <img onclick="return changeUrl('{{ .ArticleLink }}');" class="h-full min-h-24 w-full cursor-pointer select-none object-cover block absolute" src="{{ printf $.ThumbnailBaseUrl .Thumbnail }}">
+<hr class="first-of-type:hidden border-t-[0.375rem] my-1 w-24 mx-auto border-dotted border-main-hard">
+<div {{ if .ToHighlight }}id="blog-page-highlighted"{{ end }} class="w-full flex flex-row items-stretch mask-t-from-[calc(100%-0.5rem)] mask-t-to-100% mask-b-from-[calc(100%-0.5rem)] mask-b-to-100% {{ if .ToHighlight }}bg-accent-light bg-paper{{ end }}">
+ <div class="relative min-h-24 flex flex-col w-32 overflow-hidden shrink-0 mask-r-from-[calc(100%-1rem)] mask-r-to-100%">
+ <img onclick="return changeUrl('{{ .ArticleLink }}');" class="h-full min-h-24 w-full cursor-pointer select-none object-cover block absolute" src="{{ printf $.PhotoStorage.Thumbnail320p.BaseUrl .Thumbnail }}">
<div class="w-full h-6 flex flex-row mt-auto relative">
<div class="flex-1/2 flex flex-row justify-center select-none bg-linear-180 {{ if .Liked }}from-accent-deep/40 to-accent-deep/80 text-background-dark{{ else }}from-accent-light/40 to-accent-light/80 text-main-hard{{ end }}">
<svg viewBox="0 0 24 24"><use href="#icon-like"/></svg>
@@ -14,7 +14,7 @@
</div>
</div>
</div>
- <div class="grow w-full h-fit flex flex-col justify-center my-auto py-1 px-1">
+ <div class="grow w-full h-fit flex flex-col justify-center my-auto py-1 px-2">
<div class="flex flex-row gap-2 max-w-full">
<a href="{{ .ArticleLink }}" onclick="return changeUrl('{{ .ArticleLink }}');" class="grow-2 text-base cursor-pointer font-m-plus">
<span class="font-extrabold">{{ .Title }}</span>
@@ -24,7 +24,7 @@
<p class="basis-56 grow text-base italic text-secondary text-right">{{ .PublishedTime }}</p>
{{- end }}
</div>
- <p class="font-gentium">{{ .ShortDescription }}</p>
+ <p class="font-m-plus">{{ .ShortDescription }}</p>
{{- if not $.HideTags }}
<p class="font-m-plus text-sm">{{ $.L.TagsLabel }} {{ template "catalogue-blog-card-tags.html" . }}</p>
{{- end }}
diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html
index d697c14..b71a0d1 100644
--- a/views/partials/general-page-body.html
+++ b/views/partials/general-page-body.html
@@ -1 +1,2 @@
+<input class="htmx-exit-trigger" type="hidden" name="dummy" value="">
{{ block "body" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/general-page-header.html b/views/partials/general-page-header.html
index 3b8a6c8..a75d32f 100644
--- a/views/partials/general-page-header.html
+++ b/views/partials/general-page-header.html
@@ -1,8 +1,8 @@
-<title>{{ .Title }} // SAYA.UZ</title>
+<title>{{ or .PageTitle .Title }}</title>
<div class="flex flex-row mb-2">
{{ block "header" . }}{{ end }}
<h1
- class="text-4xl font-m-plus font-bold italic text-shadow-[0_2px_2px_var(--color-main-soft)] text-left my-auto"
+ class="text-4xl font-m-plus font-bold italic text-shadow-[0_0.125rem_0.125rem_var(--color-main-soft)] text-left my-auto"
>
{{ .Title }}
</h1>
diff --git a/views/partials/global-map-widget.html b/views/partials/global-map-widget.html
new file mode 100644
index 0000000..f50020f
--- /dev/null
+++ b/views/partials/global-map-widget.html
@@ -0,0 +1,109 @@
+<div
+ id="map-container"
+ class="bg-paper bg-background-dark flex z-10 w-full h-full"
+></div>
+
+<script>
+(function() {
+ var map;
+ var markers = L.markerClusterGroup();
+ const THUMB_BASE = "{{ .PhotoStorage.Thumbnail320p.BaseUrl }}";
+
+ function createCustomIcon(color, borderColor = "var(--color-main-soft)") {
+ const svgIcon = `
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+ <path d="M12 1C7.5 1 4 4.5 4 9c0 6.5 8 14 8 14s8-7.5 8-14c0-4.5-3.5-8-8-8zm0 11c-1.5 0-3-1.5-3-3s1.5-3 3-3 3 1.5 3 3-1.5 3-3 3z"
+ fill="${color}"
+ stroke="${borderColor}"
+ stroke-width="1"
+ stroke-linejoin="round"/>
+ </svg>
+ `;
+
+ return L.divIcon({
+ html: svgIcon,
+ className: 'custom-svg-marker',
+ iconSize: [32, 32],
+ iconAnchor: [16, 32],
+ popupAnchor: [0, -32]
+ });
+ }
+
+ function initLocationMap() {
+ map = L.map('map-container').setView([{{ .MapLocationLat }}, {{ .MapLocationLong }}], {{ .MapLocationZoom }});
+
+ protomapsL.leafletLayer({
+ url: '{{ .StaticStorage.Map.BaseUrl }}/{{ .StaticStorage.Map.PMTiles }}',
+ maxZoom: 15,
+ flavor: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
+ lang: '{{ .Lang }}'
+ }).addTo(map);
+
+ {{- with .ZoomPosition }}
+ L.control.zoom({
+ position: '{{ . }}'
+ }).addTo(map);
+ {{- end }}
+
+ map.addLayer(markers);
+ }
+
+ function markerToHsl(str, newCoef) {
+ let hash = 0;
+ for (let i = 0; i < str.length / 2; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ hash = str.charCodeAt(str.length - i - 1) + ((hash << 5) - hash);
+ hash %= 360;
+ }
+
+ return [hash, (20 + newCoef * 80).toFixed(0), (20 + newCoef * 75).toFixed(0)];
+ }
+
+ function mapAddMarker(x, y, relativeErrorMeters = 0, title = "", link = "", thumbnail = "", newCoef = 1, toHighlight = false) {
+ const [h, s, l] = markerToHsl(title, newCoef);
+ const color = `hsl(${h} ${s}% ${l}%)`;
+
+ if (relativeErrorMeters != 0) {
+ var circle = L.circle([x, y], {
+ color: color,
+ fillColor: color,
+ fillOpacity: 0.15,
+ radius: relativeErrorMeters
+ }).addTo(map);
+ }
+
+ var marker = L.marker([x, y], {
+ icon: createCustomIcon(color),
+ title: title,
+ }).bindPopup(`
+ <div class="flex flex-row justify-center justify-items-center">
+ <img onclick="location.href='${link}';" class="cursor-pointer object-cover rounded-[10%] select-none w-12 h-12 mr-1" src="${THUMB_BASE.replace('%s', thumbnail)}">
+ <span><b><a href="${link}">${title}</a></b><br><a href="https://www.openstreetmap.org/#map=13/${x}/${y}">${x}, ${y}</a></span>
+ </div>`);
+
+ markers.addLayer(marker);
+
+ if (toHighlight) {
+ markers.zoomToShowLayer(marker, () => marker.openPopup());
+ }
+ }
+
+ initLocationMap();
+ {{- range .MapMarkers }}
+ mapAddMarker({{ .Lat }}, {{ .Long }}, {{ .AccuracyMeters }}, {{ .Title }}, {{ .PageLink }}, {{ .Thumbnail }}, {{ fdiv .Index (len $.MapMarkers) }}, {{ .ToHighlight }});
+ {{- end }}
+
+ function onResize() {
+ setTimeout(() => {
+ if (map) map.invalidateSize();
+ }, 300);
+ }
+ window.addEventListener('resize', onResize);
+ new MutationObserver((muts, obs) => {
+ if (!document.getElementById('map-container')) {
+ window.removeEventListener('resize', onResize);
+ obs.disconnect();
+ }
+ }).observe(document.body, { childList: true, subtree: true });
+})();
+</script>
diff --git a/views/partials/personal-page-status.html b/views/partials/personal-page-status.html
index 078fb24..41033a9 100644
--- a/views/partials/personal-page-status.html
+++ b/views/partials/personal-page-status.html
@@ -1,4 +1,4 @@
-<div id="{{ .StatusId }}" class='z-26 w-full inset-shadow-[0_0_0.4rem_black]
+<div id="{{ .StatusId }}" class='z-26 w-full inset-shadow-elevation-6
{{if eq .Status "OK"}}bg-green-700 text-green-100
{{else if eq .Status "Failed"}}bg-red-700 text-red-100
{{else}}bg-paper bg-background-dark text-main-soft{{end}}