summaryrefslogtreecommitdiff
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build-and-deploy-prod.yml78
-rw-r--r--.github/workflows/build-and-deploy-stage.yml89
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile18
-rw-r--r--config/config.go53
-rw-r--r--config/config.local.yaml47
-rw-r--r--config/config.prod.yaml46
-rw-r--r--config/config.stage.yaml46
-rw-r--r--config/config.yaml17
-rw-r--r--deploy/inventory.yml19
-rw-r--r--deploy/playbook.yml35
-rw-r--r--go.mod50
-rw-r--r--go.sum95
-rw-r--r--internal/b2/client.go20
-rw-r--r--internal/blogtrigger/blogtrigger.go72
-rw-r--r--internal/factgiver/factgiver.go67
-rw-r--r--internal/frontmatter/parser.go3
-rw-r--r--internal/glightbox/block.go32
-rw-r--r--internal/glightbox/extension.go (renamed from internal/lightgallery/extension.go)14
-rw-r--r--internal/glightbox/html_renderer.go186
-rw-r--r--internal/glightbox/parser.go100
-rw-r--r--internal/lightgallery/block.go26
-rw-r--r--internal/lightgallery/html_renderer.go137
-rw-r--r--internal/lightgallery/parser.go77
-rw-r--r--internal/mailer/mailer.go544
-rw-r--r--internal/router/api-v1-blog-search.go128
-rw-r--r--internal/router/api-v1-email-is-in-verification.go58
-rw-r--r--internal/router/api-v1-email-send-verification-code.go98
-rw-r--r--internal/router/api-v1-email-verify.go52
-rw-r--r--internal/router/api-v1-general-page-body.go243
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go86
-rw-r--r--internal/router/api-v1-general-page-footer.go86
-rw-r--r--internal/router/api-v1-general-page-header.go98
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go86
-rw-r--r--internal/router/api-v1-general-page.go59
-rw-r--r--internal/router/api-v1-like.go122
-rw-r--r--internal/router/api-v1-subs.go67
-rw-r--r--internal/router/api-v1-tz.go37
-rw-r--r--internal/router/client-cache.go320
-rw-r--r--internal/router/lang-map.go88
-rw-r--r--internal/router/lang-user-unsubscribe.go71
-rw-r--r--internal/router/page-cache.go5
-rw-r--r--internal/router/tm.go18
-rw-r--r--internal/tailwind/link_renderer.go49
-rw-r--r--internal/tailwind/transformer.go35
-rw-r--r--internal/templatemanager/templatemanager.go100
-rw-r--r--locale/localization.en.yaml74
-rw-r--r--locale/localization.go139
-rw-r--r--locale/localization.ru.yaml74
-rw-r--r--main.go223
-rw-r--r--migrations/1_create_stats_tables.down.sql2
-rw-r--r--migrations/1_create_stats_tables.up.sql11
-rw-r--r--migrations/2_create_subscription_tables.down.sql4
-rw-r--r--migrations/2_create_subscription_tables.up.sql18
-rw-r--r--static/favicon.icobin0 -> 5694 bytes
-rw-r--r--static/input.css766
-rw-r--r--static/output.css908
-rw-r--r--views/index.html142
-rw-r--r--views/layouts/blog-page.html128
-rw-r--r--views/layouts/general-mail.html105
-rw-r--r--views/layouts/general-page.html410
-rw-r--r--views/messages/new-post.html19
-rw-r--r--views/messages/verify-email.html10
-rw-r--r--views/pages/blog-catalogue.html164
-rw-r--r--views/pages/blog-page.html61
-rw-r--r--views/pages/global-map.html298
-rw-r--r--views/pages/home-page.html122
-rw-r--r--views/pages/language-pick.html16
-rw-r--r--views/pages/unsubscribe-page.html30
-rw-r--r--views/pages/user-page.html205
-rw-r--r--views/partials/blog-page-like-button.html6
-rw-r--r--views/partials/catalogue-blog-card-tags.html1
-rw-r--r--views/partials/catalogue-blog-cards.html27
-rw-r--r--views/partials/general-page-body.html2
-rw-r--r--views/partials/general-page-bottom-embeds.html1
-rw-r--r--views/partials/general-page-footer.html7
-rw-r--r--views/partials/general-page-header.html6
-rw-r--r--views/partials/general-page-top-embeds.html1
-rw-r--r--views/partials/personal-page-status.html12
79 files changed, 5938 insertions, 1834 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml
new file mode 100644
index 0000000..d57e872
--- /dev/null
+++ b/.github/workflows/build-and-deploy-prod.yml
@@ -0,0 +1,78 @@
+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@v4
+ - 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@v3
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GHCR_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ 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]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up SSH connection for pl.saya.today
+ 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 pl.saya.today >> ~/.ssh/known_hosts
+
+ - name: Test SSH connection
+ run: |
+ ssh -o ConnectTimeout=10 -i ~/.ssh/id_ed25519 svc_github@pl.saya.today "echo 'SSH connection successful'"
+
+ - name: Install Ansible
+ shell: bash
+ run: |
+ sudo apt update
+ sudo apt install -y ansible
+
+ - name: Run Ansible playbook
+ env:
+ ANSIBLE_HOST_KEY_CHECKING: False
+ working-directory: ./deploy
+ run: |
+ ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt="${{ secrets.AUTH_SALT }}" -e saya_today_web_b2_key_id="${{ secrets.B2_KEY_ID }}" -e saya_today_web_b2_application_key="${{ secrets.B2_APPLICATION_KEY }}" -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} -e saya_today_web_mail_salt="${{ secrets.MAIL_SALT }}" -e saya_today_web_mail_host="${{ secrets.MAIL_HOST }}" -e saya_today_web_mail_address="${{ secrets.MAIL_ADDRESS }}" -e saya_today_web_mail_username="${{ secrets.MAIL_USERNAME }}" -e saya_today_web_mail_password="${{ secrets.MAIL_PASSWORD }}"
diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml
new file mode 100644
index 0000000..16e6563
--- /dev/null
+++ b/.github/workflows/build-and-deploy-stage.yml
@@ -0,0 +1,89 @@
+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
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - 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@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GHCR_TOKEN }}
+
+ - name: Set github short sha
+ id: ghss_vars
+ run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ 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]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up SSH connection for pl.saya.today
+ 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 pl.saya.today >> ~/.ssh/known_hosts
+
+ - name: Test SSH connection
+ run: |
+ ssh -o ConnectTimeout=10 -i ~/.ssh/id_ed25519 svc_github@pl.saya.today "echo 'SSH connection successful'"
+
+ - name: Install Ansible
+ shell: bash
+ run: |
+ sudo apt update
+ sudo apt install -y ansible
+
+ - name: Set github short sha
+ id: ghss_vars
+ run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+
+ - name: Run Ansible playbook
+ env:
+ ANSIBLE_HOST_KEY_CHECKING: False
+ working-directory: ./deploy
+ run: |
+ ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt="${{ secrets.AUTH_SALT }}" -e saya_today_web_b2_key_id="${{ secrets.B2_KEY_ID }}" -e saya_today_web_b2_application_key="${{ secrets.B2_APPLICATION_KEY }}" -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} -e saya_today_web_mail_salt="${{ secrets.MAIL_SALT }}" -e saya_today_web_mail_host="${{ secrets.MAIL_HOST }}" -e saya_today_web_mail_address="${{ secrets.MAIL_ADDRESS }}" -e saya_today_web_mail_username="${{ secrets.MAIL_USERNAME }}" -e saya_today_web_mail_password="${{ secrets.MAIL_PASSWORD }}"
diff --git a/.gitignore b/.gitignore
index 9fb93e9..b5ae410 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,9 @@ config*.json
/main
/sayana-demo
+/static/output.css
/.vscode
.envrc
+
+/.ansible
diff --git a/Dockerfile b/Dockerfile
index d23c3eb..eeee6c5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,18 +1,26 @@
-FROM golang:1.24.5-alpine3.22 AS build-stage
+FROM golang:1.24.6-alpine3.22 AS build-stage
+
+RUN apk add --no-cache sqlite-dev musl-dev gcc
WORKDIR /builddir
COPY . .
-RUN go build -o sayana-demo .
+RUN CGO_ENABLED=1 go build -o sayana-web .
FROM alpine:3.22.1 AS runtime-stage
+ENV ENVIRONMENT=""
+ENV AUTH_SALT=""
ENV B2_KEY_ID=""
ENV B2_APPLICATION_KEY=""
WORKDIR /app
-COPY --from=build-stage /builddir/sayana-demo /app/sayana-demo
+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/config/config.yaml /app/config.yaml
+COPY --from=build-stage /builddir/locale/*.yaml /app/locale/
+COPY --from=build-stage /builddir/config/config*.yaml /app/config/
+
+RUN apk add --no-cache tzdata sqlite
-ENTRYPOINT [ "/app/sayana-demo", "-c", "/app/config.yaml" ]
+ENTRYPOINT /app/sayana-web -c /app/config/config.${ENVIRONMENT}.yaml
diff --git a/config/config.go b/config/config.go
index 67c0c4d..1393dd8 100644
--- a/config/config.go
+++ b/config/config.go
@@ -9,13 +9,17 @@ import (
)
type Config struct {
- LogLevel slog.Level `json:"LogLevel" yaml:"logLevel" validate:"required"`
- BlogPages BlogPagesConfig `json:"BlogPages" yaml:"blogPages" validate:"required"`
+ LogLevel slog.Level `json:"LogLevel" yaml:"logLevel" validate:"required"`
+ BlogPages BlogPagesConfig `json:"BlogPages" yaml:"blogPages" validate:"required"`
+ FactGiver FactGiverConfig `json:"FactGiver" yaml:"factGiver" validate:"required"`
+ LocalePath string `json:"LocalePath" yaml:"localePath" validate:"required,filepath"`
+ AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"`
+ Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"`
+ Mail MailConfig `json:"Mail" yaml:"mail" validate:"required"`
}
type BlogPagesConfig struct {
- Storage StorageConfig `json:"Storage" yaml:"storage" validate:"required"`
- AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"`
+ Storage StorageConfig `json:"Storage" yaml:"storage" validate:"required"`
}
type StorageConfig struct {
@@ -31,10 +35,45 @@ type B2Config struct {
ApplicationKey string `json:"ApplicationKey" yaml:"applicationKey"`
}
+type FactGiverConfig struct {
+ Storage StorageConfig `json:"Storage" yaml:"storage" validate:"required"`
+ FactsFileName string `json:"FactsFileName" yaml:"factsFileName" validate:"required"`
+}
+
type AvailableLanguageConfig struct {
- Name string `json:"Name" yaml:"name" validate:"required"`
- Alt string `json:"Alt" yaml:"alt"`
- Flag string `json:"Flag" yaml:"flag" validate:"url"`
+ Name string `json:"Name" yaml:"name" validate:"required"`
+ Alt string `json:"Alt" yaml:"alt"`
+ Flag string `json:"Flag" yaml:"flag" validate:"url"`
+ LocFile string `json:"LocFile" yaml:"locFile" validate:"required,filepath"`
+}
+
+type AuthConfig struct {
+ Salt string `json:"Salt" yaml:"salt" validate:"required"`
+ Db DbConfig `json:"Db" yaml:"db" validate:"required"`
+}
+
+type DbConfig struct {
+ Type string `json:"Type" yaml:"type" validate:"required,oneof=sqlite3"`
+ Cfg Sqlite3Config `json:"Config" yaml:"config"`
+}
+
+type Sqlite3Config struct {
+ DSN string `json:"DSN" yaml:"dsn" validate:"required"`
+}
+
+type MailConfig struct {
+ ClientHost string `json:"ClientHost" yaml:"clientHost" validate:"required"`
+ MailHost string `json:"MailHost" yaml:"mailHost" validate:"required"`
+ PublicName string `json:"PublicName" yaml:"publicName" validate:"required"`
+ MailAddress string `json:"MailAddress" yaml:"mailAddress" validate:"required"`
+ Username string `json:"Username" yaml:"username" validate:"required"`
+ Password string `json:"Password" yaml:"password" validate:"required"`
+ Salt string `json:"Salt" yaml:"salt" validate:"required"`
+ Trigger TriggerConfig `json:"Trigger" yaml:"trigger" validate:"required"`
+}
+
+type TriggerConfig struct {
+ OnNewPost string `json:"OnNewPost" yaml:"onNewPost" validate:"cron,required"`
}
func LoadConfig(path string, config *Config) error {
diff --git a/config/config.local.yaml b/config/config.local.yaml
new file mode 100644
index 0000000..e23c808
--- /dev/null
+++ b/config/config.local.yaml
@@ -0,0 +1,47 @@
+logLevel: debug
+blogPages:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: 'stage-'
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+factGiver:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: ''
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+ factsFileName: 'facts-*.txt'
+localePath: ./locale/
+availableLanguages:
+ - name: ru
+ alt: Русский
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/ru.svg
+ locFile: localization.ru.yaml
+ - name: en
+ alt: English
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg
+ locFile: localization.en.yaml
+auth:
+ db:
+ type: sqlite3
+ config:
+ # dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc&_journal_mode=WAL'
+ dsn: 'file:/tmp/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL'
+ salt: '123'
+mail:
+ clientHost: '127.0.0.1:3000'
+ mailHost: '${MAIL_HOST}'
+ publicName: 'LOCAL.SAYA.TODAY'
+ mailAddress: '${MAIL_ADDRESS}'
+ username: '${MAIL_USERNAME}'
+ password: '${MAIL_PASSWORD}'
+ salt: '${MAIL_SALT}'
+ trigger:
+ onNewPost: "* * * * *"
diff --git a/config/config.prod.yaml b/config/config.prod.yaml
new file mode 100644
index 0000000..0bee79d
--- /dev/null
+++ b/config/config.prod.yaml
@@ -0,0 +1,46 @@
+logLevel: info
+blogPages:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: 'prod-'
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+factGiver:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: ''
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+ factsFileName: 'facts-*.txt'
+localePath: ./locale/
+availableLanguages:
+ - name: ru
+ alt: Русский
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/ru.svg
+ locFile: localization.ru.yaml
+ - name: en
+ alt: English
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg
+ locFile: localization.en.yaml
+auth:
+ db:
+ type: sqlite3
+ config:
+ dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL'
+ salt: '${AUTH_SALT}'
+mail:
+ clientHost: '${FQDN}'
+ mailHost: '${MAIL_HOST}'
+ publicName: 'SAYA.TODAY'
+ mailAddress: '${MAIL_ADDRESS}'
+ username: '${MAIL_USERNAME}'
+ password: '${MAIL_PASSWORD}'
+ salt: '${MAIL_SALT}'
+ trigger:
+ onNewPost: "0/5 * * * *"
diff --git a/config/config.stage.yaml b/config/config.stage.yaml
new file mode 100644
index 0000000..1c15dae
--- /dev/null
+++ b/config/config.stage.yaml
@@ -0,0 +1,46 @@
+logLevel: debug
+blogPages:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: 'stage-'
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+factGiver:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: ''
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+ factsFileName: 'facts-*.txt'
+localePath: ./locale/
+availableLanguages:
+ - name: ru
+ alt: Русский
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/ru.svg
+ locFile: localization.ru.yaml
+ - name: en
+ alt: English
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg
+ locFile: localization.en.yaml
+auth:
+ db:
+ type: sqlite3
+ config:
+ dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL'
+ salt: '${AUTH_SALT}'
+mail:
+ clientHost: '${FQDN}'
+ mailHost: '${MAIL_HOST}'
+ publicName: 'DEMO.SAYA.TODAY'
+ mailAddress: '${MAIL_ADDRESS}'
+ username: '${MAIL_USERNAME}'
+ password: '${MAIL_PASSWORD}'
+ salt: '${MAIL_SALT}'
+ trigger:
+ onNewPost: "0/3 * * * *"
diff --git a/config/config.yaml b/config/config.yaml
deleted file mode 100644
index 908bfe2..0000000
--- a/config/config.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-logLevel: debug
-blogPages:
- storage:
- type: b2
- config:
- bucketName: sayana-pages
- region: eu-central-003
- prefix: ''
- keyID: '${B2_KEY_ID}'
- applicationKey: '${B2_APPLICATION_KEY}'
- availableLanguages:
- - name: ru
- alt: Русский
- flag: https://f003.backblazeb2.com/file/sayana-static/flags/ru.svg
- - name: en
- alt: English
- flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg
diff --git a/deploy/inventory.yml b/deploy/inventory.yml
new file mode 100644
index 0000000..a1ecf5d
--- /dev/null
+++ b/deploy/inventory.yml
@@ -0,0 +1,19 @@
+all:
+ children:
+ prod:
+ hosts:
+ node_prod:
+ ansible_host: pl.saya.today
+ saya_today_web_name: sayana-web
+ saya_today_web_hostname: sayana-web
+ saya_today_web_ipv4_address: 172.16.0.18
+ saya_today_web_listen_address: saya.today
+
+ stage:
+ hosts:
+ node_stage:
+ ansible_host: pl.saya.today
+ saya_today_web_name: sayana-demo
+ saya_today_web_hostname: sayana-demo
+ saya_today_web_ipv4_address: 172.16.0.17
+ saya_today_web_listen_address: demo.saya.today
diff --git a/deploy/playbook.yml b/deploy/playbook.yml
new file mode 100644
index 0000000..2516f4e
--- /dev/null
+++ b/deploy/playbook.yml
@@ -0,0 +1,35 @@
+---
+- name: Deploy saya-today-web Docker Container
+ hosts: all
+ remote_user: svc_github
+
+ tasks:
+ - name: Create a volume
+ community.docker.docker_volume:
+ name: "{{ saya_today_web_name }}-volume"
+
+ - name: Deploy saya-today-web Docker Container
+ community.docker.docker_container:
+ name: "{{ saya_today_web_name }}"
+ hostname: "{{ saya_today_web_hostname }}"
+ image: "ghcr.io/sayaandy/saya-today-web:{{ saya_today_web_tag }}"
+ env:
+ B2_KEY_ID: "{{ saya_today_web_b2_key_id }}"
+ B2_APPLICATION_KEY: "{{ saya_today_web_b2_application_key }}"
+ ENVIRONMENT: "{{ saya_today_web_environment }}"
+ AUTH_SALT: "{{ saya_today_web_auth_salt }}"
+ MAIL_HOST: "{{ saya_today_web_mail_host }}"
+ MAIL_ADDRESS: "{{ saya_today_web_mail_address }}"
+ MAIL_USERNAME: "{{ saya_today_web_mail_username }}"
+ MAIL_PASSWORD: "{{ saya_today_web_mail_password }}"
+ MAIL_SALT: "{{ saya_today_web_mail_salt }}"
+ FQDN: "{{ saya_today_web_listen_address }}"
+ network_mode: caddy
+ networks:
+ - name: caddy
+ ipv4_address: "{{ saya_today_web_ipv4_address }}"
+ volumes:
+ - "{{ saya_today_web_name }}-volume:/data:rw"
+ restart_policy: unless-stopped
+ no_log: true
+...
diff --git a/go.mod b/go.mod
index c6f267d..4ea7ede 100644
--- a/go.mod
+++ b/go.mod
@@ -1,33 +1,43 @@
module github.com/SayaAndy/saya-today-web
-go 1.24.5
+go 1.24.6
-require github.com/gofiber/fiber/v2 v2.52.9
+require (
+ github.com/Backblaze/blazer v0.7.2
+ github.com/go-playground/validator/v10 v10.27.0
+ github.com/gofiber/fiber/v2 v2.52.9
+ github.com/golang-migrate/migrate/v4 v4.18.3
+ github.com/mattn/go-sqlite3 v1.14.32
+ github.com/yuin/goldmark v1.7.13
+ golang.org/x/crypto v0.41.0
+ gopkg.in/yaml.v3 v3.0.1
+)
require (
- github.com/Backblaze/blazer v0.7.2 // indirect
- github.com/andybalholm/brotli v1.1.0 // indirect
- github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.10 // indirect
+ github.com/go-co-op/gocron/v2 v2.17.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.27.0 // indirect
- github.com/gofiber/template v1.8.3 // indirect
- github.com/gofiber/template/html/v2 v2.1.3 // indirect
- github.com/gofiber/utils v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/klauspost/compress v1.17.9 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/jonboulle/clockwork v0.5.0 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasthttp v1.51.0 // indirect
- github.com/valyala/tcplisten v1.0.0 // indirect
- github.com/yuin/goldmark v1.7.13 // indirect
- golang.org/x/crypto v0.33.0 // indirect
- golang.org/x/net v0.34.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
- golang.org/x/text v0.22.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ github.com/valyala/fasthttp v1.65.0 // indirect
+ github.com/wneessen/go-mail v0.7.2 // indirect
+ go.uber.org/atomic v1.7.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
)
diff --git a/go.sum b/go.sum
index e418881..dd213f1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,22 @@
github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQM=
github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10=
-github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
-github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
-github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
-github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
+github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
+github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
+github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@@ -12,47 +25,67 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
-github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
-github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
-github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
-github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
-github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
-github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
+github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
+github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
-github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
+github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
-github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
-github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
-github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
+github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
+github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
+github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
-golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
-golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
-golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
-golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/b2/client.go b/internal/b2/client.go
index f2b6cad..d712736 100644
--- a/internal/b2/client.go
+++ b/internal/b2/client.go
@@ -3,6 +3,7 @@ package b2
import (
"context"
"fmt"
+ "slices"
"strings"
"time"
@@ -33,13 +34,15 @@ func NewB2Client(cfg *config.B2Config) (*B2Client, error) {
type BlogPage struct {
Link string
+ FileName string
+ Lang string
Metadata *frontmatter.Metadata
}
-func (c *B2Client) Scan() ([]*BlogPage, error) {
+func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
filePaths := []*BlogPage{}
- iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix))
+ iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix))
for iter.Next() {
obj := iter.Object()
@@ -69,17 +72,24 @@ func (c *B2Client) Scan() ([]*BlogPage, error) {
return nil, fmt.Errorf("failed to parse published time metadata field: %w", err)
}
- linkParts := strings.Split(obj.Name(), ".")
+ linkParts := strings.Split(obj.Name(), "/")
+ nameParts := strings.Split(linkParts[len(linkParts)-1], ".")
+ fileName := strings.Join(nameParts[:len(linkParts)-1], ".")
+
+ tags := strings.Split(attrs.Info["tags"], ",")
+ slices.Sort(tags)
filePaths = append(filePaths, &BlogPage{
- Link: strings.Join(linkParts[:len(linkParts)-1], "."),
+ Link: obj.Name(),
+ FileName: fileName,
Metadata: &frontmatter.Metadata{
Title: attrs.Info["title"],
ShortDescription: attrs.Info["short-description"],
ActionDate: attrs.Info["action-date"],
PublishedTime: publishedTime,
Thumbnail: attrs.Info["thumbnail"],
- Tags: strings.Split(attrs.Info["tags"], ","),
+ Tags: tags,
+ Geolocation: attrs.Info["geolocation"],
},
})
}
diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go
new file mode 100644
index 0000000..c5b3097
--- /dev/null
+++ b/internal/blogtrigger/blogtrigger.go
@@ -0,0 +1,72 @@
+package blogtrigger
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/go-co-op/gocron/v2"
+)
+
+type BlogTriggerScheduler struct {
+ s gocron.Scheduler
+ knownBlogPages map[string]map[string]*b2.BlogPage
+ b2Client *b2.B2Client
+ onTrigger func([]*b2.BlogPage) error
+}
+
+func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, error) {
+ s, err := gocron.NewScheduler()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create new scheduler: %w", err)
+ }
+
+ knownBlogPages := make(map[string]map[string]*b2.BlogPage, len(availableLanguages))
+ for _, lang := range availableLanguages {
+ knownBlogPages[lang.Name] = make(map[string]*b2.BlogPage)
+ }
+
+ bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger}
+ defer bts.s.Start()
+
+ bts.s.NewJob(gocron.CronJob(cron, false), gocron.NewTask(func(bts *BlogTriggerScheduler) {
+ posts, err := bts.scan()
+ if err != nil {
+ slog.Error("failed to execute scanning new blog pages cron job", slog.String("error", err.Error()))
+ return
+ }
+ if err = onTrigger(posts); err != nil {
+ slog.Error("error happened on callback function after scanning new blog pages", slog.String("error", err.Error()))
+ return
+ }
+ }, bts))
+
+ if _, err = bts.scan(); err != nil {
+ return nil, fmt.Errorf("failed to scan existing blog pages in b2: %w", err)
+ }
+
+ return bts, nil
+}
+
+func (bts *BlogTriggerScheduler) Close() error {
+ return bts.s.Shutdown()
+}
+
+func (bts *BlogTriggerScheduler) scan() (newPages []*b2.BlogPage, err error) {
+ newPages = make([]*b2.BlogPage, 0)
+ for lang := range bts.knownBlogPages {
+ posts, err := bts.b2Client.Scan(lang + "/")
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err)
+ }
+ for _, post := range posts {
+ if _, ok := bts.knownBlogPages[lang][post.FileName]; !ok {
+ post.Lang = lang
+ newPages = append(newPages, post)
+ bts.knownBlogPages[lang][post.FileName] = post
+ }
+ }
+ }
+ return newPages, nil
+}
diff --git a/internal/factgiver/factgiver.go b/internal/factgiver/factgiver.go
new file mode 100644
index 0000000..5b418ee
--- /dev/null
+++ b/internal/factgiver/factgiver.go
@@ -0,0 +1,67 @@
+package factgiver
+
+import (
+ "fmt"
+ "math/rand"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+)
+
+type FactGiver struct {
+ b2Client *b2.B2Client
+ cache map[string][]string
+ langs []config.AvailableLanguageConfig
+ factsFileName string
+ nlRe *regexp.Regexp
+ randGen *rand.Rand
+}
+
+func NewFactGiver(cfg *config.FactGiverConfig, langs []config.AvailableLanguageConfig) (*FactGiver, error) {
+ b2Client, err := b2.NewB2Client(&cfg.Storage.Config)
+ if err != nil {
+ return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error())
+ }
+
+ factGiver := &FactGiver{
+ b2Client: b2Client,
+ cache: make(map[string][]string, len(langs)),
+ langs: langs,
+ factsFileName: cfg.FactsFileName,
+ nlRe: regexp.MustCompile(`\r?\n`),
+ randGen: rand.New(rand.NewSource(time.Now().UnixNano())),
+ }
+ if err = factGiver.initCache(); err != nil {
+ return nil, fmt.Errorf("fail to init cache for a new fact giver: %s", err.Error())
+ }
+
+ return factGiver, nil
+}
+
+func (g *FactGiver) Give(lang string) [3]string {
+ factSlice := make([]string, len(g.cache[lang]))
+ copy(factSlice, g.cache[lang])
+ g.randGen.Shuffle(len(factSlice), func(i, j int) {
+ factSlice[i], factSlice[j] = factSlice[j], factSlice[i]
+ })
+ return [3]string{factSlice[0], factSlice[1], factSlice[2]}
+}
+
+func (g *FactGiver) initCache() error {
+ for _, lang := range g.langs {
+ localFacts := strings.Replace(g.factsFileName, "*", lang.Name, 1)
+ factsContentBytes, err := g.b2Client.ReadAll(localFacts)
+ if err != nil {
+ return fmt.Errorf("fail to read '%s' facts file: %s", lang.Name, err.Error())
+ }
+ factsContent := string(factsContentBytes)
+ g.cache[lang.Name] = g.nlRe.Split(factsContent, -1)
+ if g.cache[lang.Name][len(g.cache[lang.Name])-1] == "" {
+ g.cache[lang.Name] = g.cache[lang.Name][:len(g.cache[lang.Name])-1]
+ }
+ }
+ return nil
+}
diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go
index 6cc3ee8..83312dc 100644
--- a/internal/frontmatter/parser.go
+++ b/internal/frontmatter/parser.go
@@ -15,6 +15,9 @@ type Metadata struct {
PublishedTime time.Time `yaml:"publishedTime"`
Thumbnail string `yaml:"thumbnail"`
Tags []string `yaml:"tags"`
+ Geolocation string `yaml:"geolocation"`
+ Medley string `yaml:"medley"`
+ MedleyPart int `yaml:"medleyPart"`
}
func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) {
diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go
new file mode 100644
index 0000000..e53e67b
--- /dev/null
+++ b/internal/glightbox/block.go
@@ -0,0 +1,32 @@
+package glightbox
+
+import (
+ "time"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+// GLightboxBlock represents a light gallery block in the AST
+type GLightboxBlock struct {
+ ast.BaseBlock
+ Images []GLightboxImage
+ Location *time.Location
+}
+
+type GLightboxImage struct {
+ URL string
+ Tags []string
+ Caption []byte
+}
+
+var KindGLightboxBlock = ast.NewNodeKind("GLightboxBlock")
+
+// Dump implements ast.Node.Dump
+func (n *GLightboxBlock) Dump(source []byte, level int) {
+ ast.DumpHelper(n, source, level, nil, nil)
+}
+
+// Kind implements ast.Node.Kind
+func (n *GLightboxBlock) Kind() ast.NodeKind {
+ return KindGLightboxBlock
+}
diff --git a/internal/lightgallery/extension.go b/internal/glightbox/extension.go
index 761dc05..7fd17b1 100644
--- a/internal/lightgallery/extension.go
+++ b/internal/glightbox/extension.go
@@ -1,4 +1,4 @@
-package lightgallery
+package glightbox
import (
"github.com/yuin/goldmark"
@@ -8,21 +8,21 @@ import (
)
// Extension that combines parser and renderer
-type LightGalleryExtension struct{}
+type GLightboxExtension struct{}
-func NewLightGalleryExtension() goldmark.Extender {
- return &LightGalleryExtension{}
+func NewGLightboxExtension() goldmark.Extender {
+ return &GLightboxExtension{}
}
-func (e *LightGalleryExtension) Extend(m goldmark.Markdown) {
+func (e *GLightboxExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithBlockParsers(
- util.Prioritized(NewLightGalleryParser(), 500),
+ util.Prioritized(NewGLightboxParser(), 500),
),
)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(
- util.Prioritized(NewLightGalleryHTMLRenderer(), 500),
+ util.Prioritized(NewGLightboxHTMLRenderer(), 500),
),
)
}
diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go
new file mode 100644
index 0000000..7022793
--- /dev/null
+++ b/internal/glightbox/html_renderer.go
@@ -0,0 +1,186 @@
+package glightbox
+
+import (
+ "bytes"
+ "fmt"
+ "math/rand"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/tailwind"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+type GLightboxHTMLRenderer struct {
+ html.Config
+ md goldmark.Markdown
+ anchorMatchRe *regexp.Regexp
+}
+
+func NewGLightboxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &GLightboxHTMLRenderer{
+ Config: html.NewConfig(),
+ md: goldmark.New(
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ parser.WithAttribute(),
+ ),
+ goldmark.WithRenderer(
+ renderer.NewRenderer(
+ renderer.WithNodeRenderers(
+ util.Prioritized(tailwind.NewCustomLinkRenderer(
+ html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(),
+ ), 50),
+ util.Prioritized(html.NewRenderer(
+ html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(),
+ ), 100),
+ ),
+ ),
+ ),
+ ),
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ r.anchorMatchRe = regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`)
+ return r
+}
+
+func (r *GLightboxHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(KindGLightboxBlock, r.renderGLightbox)
+}
+
+func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ gallery := n.(*GLightboxBlock)
+
+ if len(gallery.Images) == 0 {
+ return ast.WalkContinue, nil
+ }
+
+ galleryID := generateDivId(8)
+
+ var elements []string
+ for i, img := range gallery.Images {
+ imageUrlSegments := strings.Split(img.URL, ".")
+ imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".")
+ imageUrlParts := strings.Split(imageUrlWithoutExt, "/")
+ imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-")
+
+ var captionBuf bytes.Buffer
+ captionHTML := img.Caption
+ if err := r.md.Convert(img.Caption, &captionBuf); err == nil {
+ captionHTML = captionBuf.Bytes()
+ captionHTML = bytes.TrimPrefix(captionHTML, []byte("<p>"))
+ captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>\n"))
+ captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>"))
+ }
+
+ dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1])
+ dayDate = dayDate.In(gallery.Location)
+
+ glightboxDescId := ""
+ if len(captionHTML) != 0 {
+ glightboxDescId = fmt.Sprintf("glightbox-desc-%s-%d", galleryID, i)
+ }
+
+ dataDescriptionAttribute := ""
+ if glightboxDescId != "" {
+ dataDescriptionAttribute = fmt.Sprintf("data-description=\".%s\"", glightboxDescId)
+ }
+
+ tagClassList := make([]string, 0, len(img.Tags))
+ for _, tag := range img.Tags {
+ switch tag {
+ case "2x":
+ tagClassList = append(tagClassList, "grid-item-2x")
+ }
+ }
+
+ if len(img.Caption) > 0 {
+ tagClassList = append(tagClassList, "grid-tooltip")
+ }
+
+ anchorlessCaptionHTML := r.anchorMatchRe.ReplaceAll(captionHTML, []byte("<span class=\"linklike\" $1 $3>$4</span>"))
+
+ elements = append(elements, fmt.Sprintf(`
+ <a href="https://f003.backblazeb2.com/file/sayana-photos/full/%s" class="glightbox grid-item %s grid-item-%s p-1"
+ data-gallery="gallery" data-title="%s" %s>
+ <picture>
+ <source media="(width < 800px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp" />
+ <source media="(width < 2400px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" />
+ <source media="(width < 3200px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp" />
+ <source media="(width < 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp" />
+ <source media="(width >= 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp" />
+ <img src="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" />
+ </picture>
+ <span class="grid-tooltip-text"><p>%s</p></span>
+ <span class="grid-item-index">%d</span>
+ </a>`, img.URL, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1))
+
+ if glightboxDescId != "" {
+ elements = append(elements, fmt.Sprintf(`
+ <div class="glightbox-desc rounded-4 %s">
+ <p>%s</p>
+ </div>`, glightboxDescId, captionHTML))
+ }
+ }
+
+ w.WriteString(fmt.Sprintf(`
+<div class="justify-content-center display-block">
+ <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto">
+ <div class="grid masonry-grid-%s mx-auto">
+ <div class="grid-sizer grid-sizer-%s"></div>
+ %s
+ </div>
+ <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto">
+</div>`, galleryID, galleryID, strings.Join(elements, "\n")))
+
+ w.WriteString(fmt.Sprintf(`
+<script>
+ var msnry_%s = new Masonry('.masonry-grid-%s', {
+ itemSelector: '.grid-item-%s',
+ columnWidth: '.grid-sizer-%s',
+ percentPosition: false,
+ fitWidth: true
+ });
+
+ var imgLoad_%s_timer;
+ var imgLoad_%s = imagesLoaded('.masonry-grid-%s');
+
+ function initMasonryLayout_%s(tm) {
+ clearTimeout(imgLoad_%s_timer);
+ imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100);
+ }
+
+ imgLoad_%s.on('progress', initMasonryLayout_%s);
+
+ window.addEventListener('resize', initMasonryLayout_%s);
+
+ document.addEventListener('popout', (e) => {
+ window.removeEventListener('resize', initMasonryLayout_%s);
+ imgLoad_%s.off('progress', initMasonryLayout_%s);
+ initMasonryLayout_%s = null;
+ imgLoad_%s = null;
+ }, {once: true});
+</script>`, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID))
+ }
+
+ return ast.WalkContinue, nil
+}
+
+func generateDivId(length int) string {
+ const charset = "abcdefghijklmnopqrstuvwxyz"
+ seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time
+ b := make([]byte, length)
+ for i := range b {
+ b[i] = charset[seededRand.Intn(len(charset))]
+ }
+ return string(b)
+}
diff --git a/internal/glightbox/parser.go b/internal/glightbox/parser.go
new file mode 100644
index 0000000..6780090
--- /dev/null
+++ b/internal/glightbox/parser.go
@@ -0,0 +1,100 @@
+package glightbox
+
+import (
+ "bytes"
+ "log/slog"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+)
+
+type GLightboxParser struct{}
+
+func NewGLightboxParser() parser.BlockParser {
+ return &GLightboxParser{}
+}
+
+func (p *GLightboxParser) Trigger() []byte {
+ return []byte{'{'}
+}
+
+func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
+ line, _ := reader.PeekLine()
+
+ if !bytes.HasPrefix(line, []byte("{Gallery:")) {
+ return nil, parser.NoChildren
+ }
+
+ r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`)
+
+ trimmed := bytes.TrimSpace(line)
+ parts := r.FindSubmatch(trimmed)
+ if len(parts) < 2 {
+ slog.Warn("invalid gallery header format", slog.String("line", string(trimmed)), slog.Int("submatch_count", len(parts)))
+ return nil, parser.NoChildren
+ }
+
+ loc, err := time.LoadLocation(string(parts[1]))
+ if err != nil {
+ slog.Warn("invalid location specified for a gallery", slog.String("error", err.Error()), slog.String("line", string(trimmed)), slog.String("location", string(parts[1])))
+ return nil, parser.NoChildren
+ }
+
+ return &GLightboxBlock{Location: loc}, parser.NoChildren
+}
+
+func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
+ line, segment := reader.PeekLine()
+ if len(line) == 0 || segment.Len() == 0 {
+ return parser.Continue | parser.NoChildren
+ }
+
+ trimmed := bytes.TrimSpace(line)
+ if bytes.Equal(trimmed, []byte("{Gallery}")) || bytes.Equal(trimmed, []byte("{/Gallery}")) {
+ reader.AdvanceLine()
+ return parser.Close
+ }
+
+ gallery := node.(*GLightboxBlock)
+
+ parts := bytes.SplitN(trimmed, []byte{'|'}, 3)
+ url := bytes.TrimSpace(parts[0])
+
+ caption := make([]byte, 0)
+ tagsRaw := ""
+ if len(parts) == 2 {
+ caption = bytes.TrimSpace(parts[1])
+ }
+ if len(parts) >= 3 {
+ tagsRaw = string(parts[1])
+ caption = bytes.TrimSpace(parts[2])
+ }
+
+ tags := strings.Split(tagsRaw, ",")
+ for i := range tags {
+ tags[i] = strings.TrimSpace(tags[i])
+ }
+
+ gallery.Images = append(gallery.Images, GLightboxImage{
+ URL: string(url),
+ Tags: tags,
+ Caption: caption,
+ })
+
+ return parser.Continue | parser.NoChildren
+}
+
+func (p *GLightboxParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
+}
+
+func (p *GLightboxParser) CanInterruptParagraph() bool {
+ return true
+}
+
+func (p *GLightboxParser) CanAcceptIndentedLine() bool {
+ return false
+}
diff --git a/internal/lightgallery/block.go b/internal/lightgallery/block.go
deleted file mode 100644
index dade2ea..0000000
--- a/internal/lightgallery/block.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package lightgallery
-
-import "github.com/yuin/goldmark/ast"
-
-// LightGalleryBlock represents a light gallery block in the AST
-type LightGalleryBlock struct {
- ast.BaseBlock
- Images []LightGalleryImage
-}
-
-type LightGalleryImage struct {
- URL string
- Caption string
-}
-
-var KindLightGalleryBlock = ast.NewNodeKind("LightGalleryBlock")
-
-// Dump implements ast.Node.Dump
-func (n *LightGalleryBlock) Dump(source []byte, level int) {
- ast.DumpHelper(n, source, level, nil, nil)
-}
-
-// Kind implements ast.Node.Kind
-func (n *LightGalleryBlock) Kind() ast.NodeKind {
- return KindLightGalleryBlock
-}
diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go
deleted file mode 100644
index fb65b06..0000000
--- a/internal/lightgallery/html_renderer.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package lightgallery
-
-import (
- "fmt"
- "math/rand"
- "strings"
- "time"
-
- "github.com/yuin/goldmark/ast"
- "github.com/yuin/goldmark/renderer"
- "github.com/yuin/goldmark/renderer/html"
- "github.com/yuin/goldmark/util"
-)
-
-type LightGalleryHTMLRenderer struct {
- html.Config
-}
-
-func NewLightGalleryHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
- r := &LightGalleryHTMLRenderer{
- Config: html.NewConfig(),
- }
- for _, opt := range opts {
- opt.SetHTMLOption(&r.Config)
- }
- return r
-}
-
-func (r *LightGalleryHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
- reg.Register(KindLightGalleryBlock, r.renderLightGallery)
-}
-
-func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
- if entering {
- gallery := n.(*LightGalleryBlock)
-
- if len(gallery.Images) == 0 {
- return ast.WalkContinue, nil
- }
-
- galleryID := generateDivId(4)
-
- w.WriteString(fmt.Sprintf(`
-<div class="justify-content-center display-block m-2">
- <hr class="border-t-4 border-dotted border-main-dark mt-vmin0-8 mb-vmin0-8 w-p80 ml-auto mr-auto">
- <div id="lg-%s" class="inline-gallery-container relative ml-auto mr-auto"></div>
- <hr class="border-t-4 border-dotted border-main-dark mt-vmin0-8 mb-vmin0-8 w-p80 ml-auto mr-auto">
-</div>`, galleryID))
-
- var dynamicElements []string
- for _, img := range gallery.Images {
- imageUrlSegments := strings.Split(img.URL, ".")
- imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".")
- imageNameParts := strings.Split(imageUrlWithoutExt, "-")
-
- dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1])
- dynamicElements = append(dynamicElements, fmt.Sprintf(`{
- src:
- "https://f003.backblazeb2.com/file/sayana-photos/full/%s",
- downloadUrl:
- "https://f003.backblazeb2.com/file/sayana-photos/full/%s",
- alt: "%s",
- sources: [{
- srcset: "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp",
- media: "(max-width: 800px)"
- }],
- thumb:
- "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp",
- subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions">
- <p class="grow text-vmax1 text-left lh-0-9 font-spectral text-main-dark">%s</p>
- <p class="text-vmax1 text-right lh-0-9 font-spectral text-secondary">%s</p>
- </div>`+"`"+`
- }`, img.URL, img.URL, img.Caption, imageUrlWithoutExt, imageUrlWithoutExt, img.Caption, dayDate.Format("2006-01-02 15:04:05 -07:00")))
- }
-
- w.WriteString(fmt.Sprintf(`
-<script>
-function createLightLibrary%s() {
- const $lgContainer = document.getElementById('lg-%s');
- const inlineGallery = lightGallery($lgContainer, {
- container: $lgContainer,
- dynamic: true,
- dynamicEl: [%s],
- width: "100%%",
- height: "50vmin",
- hash: false,
- closable: false,
- showMaximizeIcon: true,
- appendSubHtmlTo: ".lg-sub-html",
- isMobile: () => { return false; },
- slideDelay: 0,
- plugins: [lgZoom, lgThumbnail],
- thumbWidth: 160,
- thumbHeight: "10vmin",
- thumbMargin: 4
- });
-
- setTimeout(() => {
- inlineGallery.openGallery();
- }, 200);
-}
-
-document.addEventListener('DOMContentLoaded', createLightLibrary%s);
-
-let resizeTimer%s;
-window.addEventListener("resize", () => {
- clearTimeout(resizeTimer%s);
- resizeTimer%s = setTimeout(() => {
- const $lgContainer = document.getElementById('lg-%s');
- $lgContainer.innerHTML = "";
- createLightLibrary%s();
- }, 1000);
-});
-</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID, galleryID, galleryID, galleryID, galleryID))
- }
-
- return ast.WalkContinue, nil
-}
-
-func escapeHTML(s string) string {
- s = strings.ReplaceAll(s, "&", "&amp;")
- s = strings.ReplaceAll(s, "<", "&lt;")
- s = strings.ReplaceAll(s, ">", "&gt;")
- s = strings.ReplaceAll(s, "\"", "&quot;")
- s = strings.ReplaceAll(s, "'", "&#39;")
- return s
-}
-
-func generateDivId(length int) string {
- const charset = "abcdefghijklmnopqrstuvwxyz"
- seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time
- b := make([]byte, length)
- for i := range b {
- b[i] = charset[seededRand.Intn(len(charset))]
- }
- return string(b)
-}
diff --git a/internal/lightgallery/parser.go b/internal/lightgallery/parser.go
deleted file mode 100644
index 0ce209c..0000000
--- a/internal/lightgallery/parser.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package lightgallery
-
-import (
- "bytes"
- "strings"
-
- "github.com/yuin/goldmark/ast"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/text"
-)
-
-type LightGalleryParser struct{}
-
-func NewLightGalleryParser() parser.BlockParser {
- return &LightGalleryParser{}
-}
-
-func (p *LightGalleryParser) Trigger() []byte {
- return []byte{'{'}
-}
-
-func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
- line, _ := reader.PeekLine()
-
- if !bytes.HasPrefix(line, []byte("{Gallery}")) {
- return nil, parser.NoChildren
- }
-
- trimmed := bytes.TrimSpace(line)
- if !bytes.Equal(trimmed, []byte("{Gallery}")) {
- return nil, parser.NoChildren
- }
-
- return &LightGalleryBlock{}, parser.NoChildren
-}
-
-func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
- line, segment := reader.PeekLine()
- if len(line) == 0 || segment.Len() == 0 {
- return parser.Close
- }
-
- trimmed := bytes.TrimSpace(line)
- if bytes.Equal(trimmed, []byte("{Gallery}")) {
- reader.AdvanceLine()
- return parser.Close
- }
-
- gallery := node.(*LightGalleryBlock)
- lineStr := string(trimmed)
-
- parts := strings.SplitN(lineStr, "|", 2)
- url := strings.TrimSpace(parts[0])
- caption := ""
-
- if len(parts) > 1 {
- caption = strings.TrimSpace(parts[1])
- }
-
- gallery.Images = append(gallery.Images, LightGalleryImage{
- URL: url,
- Caption: caption,
- })
-
- return parser.Continue | parser.NoChildren
-}
-
-func (p *LightGalleryParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
-}
-
-func (p *LightGalleryParser) CanInterruptParagraph() bool {
- return true
-}
-
-func (p *LightGalleryParser) CanAcceptIndentedLine() bool {
- return false
-}
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
new file mode 100644
index 0000000..d6dbd17
--- /dev/null
+++ b/internal/mailer/mailer.go
@@ -0,0 +1,544 @@
+package mailer
+
+import (
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+ "encoding/binary"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/dgraph-io/ristretto/v2"
+ "github.com/gofiber/fiber/v2"
+ "github.com/wneessen/go-mail"
+ "golang.org/x/crypto/argon2"
+)
+
+type Mailer struct {
+ verificationCodes *ristretto.Cache[uint64, string]
+ unsubscribeCodes *ristretto.Cache[uint64, []byte]
+ db *sql.DB
+ tm *templatemanager.TemplateManager
+ mailClient *mail.Client
+ clientHost string
+ mailAddress string
+ publicName string
+ salt []byte
+
+ lostMailMap map[string]struct {
+ Dur time.Duration
+ End time.Time
+ CodeExpiry time.Time
+ }
+ lostMailMapMutex sync.RWMutex
+
+ hashMap map[string][]byte
+ hashMapMutex sync.RWMutex
+
+ l map[string]*locale.LocaleConfig
+}
+
+type SubscriptionType int
+
+const (
+ All SubscriptionType = iota
+ None
+ Specific
+)
+
+func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) {
+ verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, string]{
+ NumCounters: 10000,
+ MaxCost: 1 << 20, // 1 MB
+ BufferItems: 64,
+ TtlTickerDurationInSec: 3600, // 1 hour
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err)
+ }
+
+ unsubscribeCodes, err := ristretto.NewCache(&ristretto.Config[uint64, []byte]{
+ NumCounters: 10000,
+ MaxCost: 1 << 20, // 1 MB
+ BufferItems: 64,
+ TtlTickerDurationInSec: 86400, // 1 day
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err)
+ }
+
+ tm, err := templatemanager.NewTemplateManager(templatemanager.TemplateManagerTemplates{
+ Name: "new-post",
+ Files: []string{"views/layouts/general-mail.html", "views/messages/new-post.html"},
+ }, templatemanager.TemplateManagerTemplates{
+ Name: "verify-email",
+ Files: []string{"views/layouts/general-mail.html", "views/messages/verify-email.html"},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize template manager for message templating: %w", err)
+ }
+
+ mailClient, err := mail.NewClient(mailHost,
+ mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSMandatory),
+ mail.WithUsername(username), mail.WithPassword(password),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize mail client: %w", err)
+ }
+
+ return &Mailer{
+ verificationCodes: verificationCodes,
+ unsubscribeCodes: unsubscribeCodes,
+ db: db,
+ clientHost: clientHost,
+ tm: tm,
+ mailClient: mailClient,
+ mailAddress: mailAddress,
+ publicName: publicName,
+ salt: salt,
+ hashMap: make(map[string][]byte),
+ lostMailMap: make(map[string]struct {
+ Dur time.Duration
+ End time.Time
+ CodeExpiry time.Time
+ }, 0),
+ l: localization}, nil
+}
+
+func (m *Mailer) GetHash(id string) []byte {
+ m.hashMapMutex.RLock()
+ if val, ok := m.hashMap[id]; ok {
+ m.hashMapMutex.RUnlock()
+ slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val)))
+ return val
+ }
+ m.hashMapMutex.RUnlock()
+
+ m.hashMapMutex.Lock()
+ defer m.hashMapMutex.Unlock()
+
+ if val, ok := m.hashMap[id]; ok {
+ slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val)))
+ return val
+ }
+
+ m.hashMap[id] = argon2.IDKey([]byte(id), m.salt, 1, 64*1024, 4, 32)
+ slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(m.hashMap[id])))
+ return m.hashMap[id]
+}
+
+func (m *Mailer) IsAllowedToRetryVerification(userId string) (retryAllowed bool, whenAllowed time.Time, codeExpiry time.Time) {
+ m.lostMailMapMutex.RLock()
+ defer m.lostMailMapMutex.RUnlock()
+ previous, ok := m.lostMailMap[userId]
+ if ok && previous.End.After(time.Now()) {
+ return false, previous.End, previous.CodeExpiry
+ }
+ return true, time.Time{}, previous.CodeExpiry
+}
+
+func (m *Mailer) MailIsTaken(email string) (bool, error) {
+ tx, err := m.db.Begin()
+ if err != nil {
+ return false, fmt.Errorf("failed to initialize transaction with db: %s", err)
+ }
+
+ slog.Debug("began db transaction", slog.String("method", "MailIsTaken"))
+ defer func(tx *sql.Tx) {
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ }
+ slog.Debug("ended db transaction", slog.String("method", "MailIsTaken"))
+ }(tx)
+
+ var rows *sql.Rows
+ if rows, err = tx.Query(`SELECT email FROM user_email_table WHERE email=? LIMIT 1;`, email); err != nil {
+ tx.Rollback()
+
+ return false, fmt.Errorf("failed to query user-email settings in db: %s", err)
+ }
+ defer rows.Close()
+
+ isTaken := rows.Next()
+ return isTaken, nil
+}
+
+func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err error) {
+ tx, err := m.db.Begin()
+ if err != nil {
+ return "", "", fmt.Errorf("failed to initialize transaction with db: %s", err)
+ }
+
+ slog.Debug("began db transaction", slog.String("method", "GetInfo"))
+ defer func(tx *sql.Tx) {
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ }
+ slog.Debug("ended db transaction", slog.String("method", "GetInfo"))
+ }(tx)
+
+ var rows *sql.Rows
+ if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, userIdHash); err != nil {
+ tx.Rollback()
+ return "", "", fmt.Errorf("failed to query user-email settings in db: %s", err)
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return "", "", nil
+ }
+
+ if err = rows.Scan(&email, &lang); err != nil {
+ return "", "", fmt.Errorf("failed to scan the result from user-email settings query: %s", err)
+ }
+ return
+}
+
+func (m *Mailer) Unsubscribe(unsubscribeCodeString string) (clientError error, serverError error) {
+ unsubscribeCode, err := strconv.ParseUint(unsubscribeCodeString, 16, 64)
+ if err != nil {
+ return fmt.Errorf("invalid unsubscribe code: %s", err), nil
+ }
+
+ userId, _ := m.unsubscribeCodes.Get(unsubscribeCode)
+ if len(userId) == 0 {
+ return fmt.Errorf("invalid unsubscribe code: have no information about it"), nil
+ }
+
+ if err = m.Subscribe(userId, None); err != nil {
+ return nil, fmt.Errorf("failed to unsubscribe: %s", err)
+ }
+
+ return nil, nil
+}
+
+func (m *Mailer) SendVerificationCode(userId string, address string, lang string) error {
+ message := mail.NewMsg()
+
+ if err := message.EnvelopeFrom(m.mailAddress); err != nil {
+ return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err)
+ }
+ if err := message.FromFormat(m.publicName, m.mailAddress); err != nil {
+ return fmt.Errorf("failed to set formatted FROM address: %w", err)
+ }
+ if err := message.To(address); err != nil {
+ return fmt.Errorf("failed to set TO address: %w", err)
+ }
+
+ message.SetMessageID()
+ message.SetDate()
+ message.SetBulk()
+
+ dur := 1 * time.Minute
+ m.lostMailMapMutex.Lock()
+ if previous, ok := m.lostMailMap[userId]; ok {
+ if time.Now().Before(previous.End) {
+ m.lostMailMapMutex.Unlock()
+ return fmt.Errorf("user is not allowed to send another verification code until %s", previous.End)
+ }
+ dur = 2 * m.lostMailMap[userId].Dur
+ }
+ m.lostMailMap[userId] = struct {
+ Dur time.Duration
+ End time.Time
+ CodeExpiry time.Time
+ }{Dur: dur, End: time.Now().Add(dur), CodeExpiry: time.Now().Add(time.Hour)}
+ m.lostMailMapMutex.Unlock()
+
+ verificationCodeBytes := make([]byte, 8)
+ rand.Read(verificationCodeBytes)
+ verificationCode := binary.LittleEndian.Uint64(verificationCodeBytes)
+
+ verificationInfo := fmt.Sprintf("%s.%s", base64.RawStdEncoding.EncodeToString([]byte(userId)), base64.RawStdEncoding.EncodeToString([]byte(address)))
+ m.verificationCodes.Set(verificationCode, verificationInfo, int64(len(verificationInfo)+8))
+
+ message.Subject(m.l[lang].Mail.VerifyEmail.Subject)
+
+ msg, err := m.tm.Render("verify-email", fiber.Map{
+ "L": m.l[lang],
+ "Lang": lang,
+ "VerificationCode": fmt.Sprintf("%X", verificationCode),
+ "ClientHost": m.clientHost,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to render message body: %w", err)
+ }
+
+ message.SetBodyString(mail.TypeTextHTML, string(msg))
+ if err := m.mailClient.DialAndSend(message); err != nil {
+ return fmt.Errorf("failed to send verification code message: %w", err)
+ }
+ slog.Debug("verification code message successfully delivered", slog.String("address", address), slog.String("user_id", userId))
+ return nil
+}
+
+func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error {
+ verificationCode, err := strconv.ParseUint(verificationCodeEncoded, 16, 64)
+ if err != nil {
+ return fmt.Errorf("failed to decode verification code from 8-byte hex: %w", err)
+ }
+
+ verificationInfo, _ := m.verificationCodes.Get(verificationCode)
+ if verificationInfo == "" {
+ return fmt.Errorf("failed to get verification info by its code (might be absent, might be empty)")
+ }
+
+ verificationSegments := strings.Split(verificationInfo, ".")
+ if len(verificationSegments) != 2 {
+ m.verificationCodes.Del(verificationCode)
+ return fmt.Errorf("invalid format of verification info: expected %d segments, got %d", 2, len(verificationSegments))
+ }
+
+ userId, err := base64.RawStdEncoding.DecodeString(verificationSegments[0])
+ if err != nil {
+ m.verificationCodes.Del(verificationCode)
+ delete(m.lostMailMap, verificationSegments[0])
+ return fmt.Errorf("could not decode user id: %s", err)
+ }
+
+ address, err := base64.RawStdEncoding.DecodeString(verificationSegments[1])
+ if err != nil {
+ m.verificationCodes.Del(verificationCode)
+ delete(m.lostMailMap, verificationSegments[0])
+ return fmt.Errorf("could not decode address: %s", err)
+ }
+
+ tx, err := m.db.Begin()
+ if err != nil {
+ return fmt.Errorf("failed to initialize transaction with db: %s", err)
+ }
+
+ slog.Debug("began db transaction", slog.String("method", "Verify"))
+ if _, err = tx.Exec(`INSERT INTO user_email_table(user_id, email, lang) VALUES(?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ email=excluded.email,
+ lang=excluded.lang;`, m.GetHash(string(userId)), address, lang); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "Verify"))
+ return fmt.Errorf("failed to configure user-email settings in db: %s", err)
+ }
+
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "Verify"))
+ return fmt.Errorf("failed to commit transaction to db: %s", err)
+ }
+ slog.Debug("ended db transaction", slog.String("method", "Verify"))
+
+ m.verificationCodes.Del(verificationCode)
+ delete(m.lostMailMap, verificationSegments[0])
+ return nil
+}
+
+func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionType, tags []string, err error) {
+ tx, err := m.db.Begin()
+ if err != nil {
+ return None, nil, fmt.Errorf("failed to initialize transaction with db: %s", err)
+ }
+
+ slog.Debug("began db transaction", slog.String("method", "GetSubscriptions"))
+ defer func(tx *sql.Tx) {
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ }
+ slog.Debug("ended db transaction", slog.String("method", "GetSubscriptions"))
+ }(tx)
+
+ hash := m.GetHash(userId)
+
+ var rows *sql.Rows
+ if rows, err = tx.Query(`SELECT tags FROM subscription_user_to_tags_table WHERE user_id=? LIMIT 1;`, hash); err != nil {
+ tx.Rollback()
+ return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err)
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return None, nil, nil
+ }
+
+ tagsString := ""
+ if err = rows.Scan(&tagsString); err != nil {
+ return None, nil, fmt.Errorf("failed to scan the result from user-to-tags query: %s", err)
+ }
+
+ switch tagsString {
+ case "":
+ return None, nil, nil
+ case "_all":
+ return All, nil, nil
+ default:
+ return Specific, strings.Split(tagsString, ","), nil
+ }
+}
+
+func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, tags ...string) error {
+ tx, err := m.db.Begin()
+ if err != nil {
+ return fmt.Errorf("failed to initialize transaction with db: %s", err)
+ }
+
+ slog.Debug("began db transaction", slog.String("method", "Subscribe"))
+
+ slices.Sort(tags)
+ tagsOutput := ""
+ switch subscriptionType {
+ case All:
+ tagsOutput = "_all"
+ case None:
+ tagsOutput = ""
+ case Specific:
+ tagsOutput = strings.Join(tags, ",")
+ }
+
+ if _, err = tx.Exec(`INSERT INTO subscription_user_to_tags_table(user_id, tags) VALUES(?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ tags=excluded.tags;`, userIdHash, tagsOutput); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "Subscribe"))
+ return fmt.Errorf("failed to configure user-to-tags table in db for the user: %s", err)
+ }
+
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "Subscribe"))
+ return fmt.Errorf("failed to commit transaction to db: %s", err)
+ }
+ slog.Debug("ended db transaction", slog.String("method", "Subscribe"))
+
+ return nil
+}
+
+func (m *Mailer) NewPost(post *b2.BlogPage) error {
+ tx, err := m.db.Begin()
+ if err != nil {
+ return fmt.Errorf("failed to initialize transaction with db: %s", err)
+ }
+
+ slog.Debug("began db transaction", slog.String("method", "NewPost"))
+ var rows *sql.Rows
+ if rows, err = tx.Query(`SELECT user_id, tags FROM subscription_user_to_tags_table;`); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewPost"))
+ return fmt.Errorf("failed to query user-to-tags table in db: %s", err)
+ }
+
+ var userId []byte
+ usersToSend := make([]struct {
+ userId []byte
+ email string
+ }, 0)
+ var tagsString string
+ i := -1
+
+rowLoop:
+ for rows.Next() {
+ i++
+ if err = rows.Scan(&userId, &tagsString); err != nil {
+ slog.Warn("failed to scan a row in user-to-tags table", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId)))
+ continue
+ }
+
+ if tagsString == "" {
+ continue
+ }
+
+ if tagsString == "_all" {
+ usersToSend = append(usersToSend, struct {
+ userId []byte
+ email string
+ }{userId, ""})
+ continue
+ }
+
+ pageTags := strings.Split(tagsString, ",")
+ for _, tag := range pageTags {
+ if _, found := slices.BinarySearch(post.Metadata.Tags, tag); found {
+ usersToSend = append(usersToSend, struct {
+ userId []byte
+ email string
+ }{userId, ""})
+ continue rowLoop
+ }
+ }
+ }
+
+ tx.Commit()
+ rows.Close()
+ slog.Debug("ended db transaction", slog.String("method", "NewPost"))
+
+ for i := range usersToSend {
+ email, lang, err := m.GetInfo(usersToSend[i].userId)
+ if err != nil {
+ slog.Warn("failed to get info about the user", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId)))
+ continue
+ }
+
+ if lang != post.Lang {
+ continue
+ }
+
+ usersToSend[i].email = email
+ }
+
+ messages := make([]*mail.Msg, 0, len(usersToSend))
+ for _, user := range usersToSend {
+ if user.email == "" {
+ continue
+ }
+ unsubscribeCodeBytes := make([]byte, 8)
+ rand.Read(unsubscribeCodeBytes)
+ unsubscribeCode := binary.LittleEndian.Uint64(unsubscribeCodeBytes)
+
+ unsubscribeFooter := strings.Replace(m.l[post.Lang].Mail.UnsubscribeFooter, "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1)
+ unsubscribeFooter = strings.Replace(unsubscribeFooter, "{/}", "</a>", 1)
+
+ msgBody, err := m.tm.Render("new-post", fiber.Map{
+ "L": m.l[post.Lang],
+ "Lang": post.Lang,
+ "Post": post,
+ "ClientHost": m.clientHost,
+ "UnsubscribeFooter": template.HTML(unsubscribeFooter),
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to render message body: %w", err)
+ }
+
+ message := mail.NewMsg()
+
+ if err := message.EnvelopeFrom(m.mailAddress); err != nil {
+ return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err)
+ }
+ if err := message.FromFormat(m.publicName, m.mailAddress); err != nil {
+ return fmt.Errorf("failed to set formatted FROM address: %w", err)
+ }
+ if err := message.To(user.email); err != nil {
+ return fmt.Errorf("failed to set TO address: %w", err)
+ }
+
+ message.SetMessageID()
+ message.SetDate()
+ message.SetBulk()
+ message.Subject(m.l[post.Lang].Mail.NewPost.Subject)
+ message.SetBodyString(mail.TypeTextHTML, string(msgBody))
+
+ m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40)
+
+ messages = append(messages, message)
+ }
+
+ if err := m.mailClient.DialAndSend(messages...); err != nil {
+ return fmt.Errorf("failed to send new post notifications: %w", err)
+ }
+ return nil
+}
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
new file mode 100644
index 0000000..5b1d14d
--- /dev/null
+++ b/internal/router/api-v1-blog-search.go
@@ -0,0 +1,128 @@
+package router
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"))
+}
+
+func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ sort := c.Query("sort")
+ lang := c.Query("lang")
+ tz := c.Query("tz")
+
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
+
+ langIsAvailable:
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ cacheKey := "blog-search." + lang + ".pages-list"
+ var pages []*b2.BlogPage
+ if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ pages, err = b2Client.Scan(lang + "/")
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err))
+ }
+ pagesBytes, _ := json.Marshal(pages)
+ PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute)
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ re, err := regexp.Compile(`tags\[\]=([\w]+)`)
+ if err != nil {
+ slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
+ }
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := re.FindAllStringSubmatch(decodedQuery, -1)
+
+ tags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ tags = append(tags, string(match[1]))
+ }
+
+ pageMeta := make([]fiber.Map, 0, len(pages))
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ if len(tags) == 0 || slices.Contains(tags, tag) {
+ pageMeta = append(pageMeta, fiber.Map{
+ "Link": page.Link,
+ "ArticleLink": "/" + lang + "/blog/" + page.FileName,
+ "Title": page.Metadata.Title,
+ "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
+ "ActionDate": page.Metadata.ActionDate,
+ "ShortDescription": page.Metadata.ShortDescription,
+ "Thumbnail": page.Metadata.Thumbnail,
+ "Tags": page.Metadata.Tags,
+ "LikeCount": CCache.GetLikeCount(page.FileName),
+ "ViewCount": CCache.GetViewCount(page.FileName),
+ })
+ break
+ }
+ }
+ }
+
+ slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
+ switch sort {
+ case "titleAsc":
+ return strings.Compare(a["Title"].(string), b["Title"].(string))
+ case "titleDesc":
+ return strings.Compare(b["Title"].(string), a["Title"].(string))
+ case "actionDateAsc":
+ return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
+ case "actionDateDesc":
+ return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
+ case "publicationDateAsc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeA.Compare(publishedTimeB)
+ case "publicationDateDesc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeB.Compare(publishedTimeA)
+ }
+ return 0
+ })
+
+ content, err := tm.Render("catalogue-blog-cards", fiber.Map{
+ "BlogPages": pageMeta,
+ "L": l[lang],
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ return c.Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-email-is-in-verification.go b/internal/router/api-v1-email-is-in-verification.go
new file mode 100644
index 0000000..83c783c
--- /dev/null
+++ b/internal/router/api-v1-email-is-in-verification.go
@@ -0,0 +1,58 @@
+package router
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Email_IsInVerification(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ id := c.IP()
+ lang := "en"
+
+ var referer, path string
+ var pathParts []string
+ var urlStruct *url.URL
+ var err error
+
+ referer = c.Get("Referer", "")
+ if referer == "" {
+ goto skipFetchingLang
+ }
+
+ urlStruct, err = url.ParseRequestURI(referer)
+ if err != nil {
+ goto skipFetchingLang
+ }
+
+ path = urlStruct.EscapedPath()
+ pathParts = strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ goto skipFetchingLang
+ }
+
+ lang = pathParts[0]
+
+ skipFetchingLang:
+ isAllowed, whenAllowed, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
+ if !isAllowed {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Neutral", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, "", map[string]string{
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+}
diff --git a/internal/router/api-v1-email-send-verification-code.go b/internal/router/api-v1-email-send-verification-code.go
new file mode 100644
index 0000000..9372eb3
--- /dev/null
+++ b/internal/router/api-v1-email-send-verification-code.go
@@ -0,0 +1,98 @@
+package router
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Email_SendVerificationCode(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 2 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
+ }
+
+ lang := pathParts[0]
+ id := c.IP()
+
+ email := c.FormValue("email")
+ if email == "" {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailEmpty, map[string]string{})
+ }
+
+ isTaken, err := Mailer.MailIsTaken(email)
+ if err != nil {
+ slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
+ }
+ if isTaken {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailTaken, map[string]string{})
+ }
+
+ if isAllowed, whenAllowed, _ := Mailer.IsAllowedToRetryVerification(id); !isAllowed {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
+ })
+ }
+
+ if previousEmail, _, _ := Mailer.GetInfo(Mailer.GetHash(id)); previousEmail == email {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailAlreadyValidated, map[string]string{})
+ }
+
+ if err = Mailer.SendVerificationCode(id, email, lang); err != nil {
+ slog.Error("failed to send a verification code", slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
+ }
+
+ _, endTime, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.VerificationCodeSent, "{}", endTime.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(endTime.UnixMilli()),
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+}
+
+func api_v1_email_sendStatusHtml(c *fiber.Ctx, divId string, l map[string]*locale.LocaleConfig, status string, code int, lang string, message string, dataAttributes map[string]string) error {
+ sterileDataset := make(map[string]interface{})
+ for k, v := range dataAttributes {
+ sterileDataset[k] = template.HTMLAttr(fmt.Sprintf("data-%s=\"%s\"", k, template.HTMLEscapeString(v)))
+ }
+
+ content, err := tm.Render("personal-page-status", fiber.Map{
+ "L": l[lang],
+ "Status": status,
+ "Message": message,
+ "StatusId": divId,
+ "DataAttributes": sterileDataset,
+ })
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ slog.Error("failed to render the email status message", slog.String("error", err.Error()), slog.String("div_id", divId), slog.String("message", message))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("Failed to render the email status message, please ask administrator for a more detailed cause")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(code).Send(content)
+}
diff --git a/internal/router/api-v1-email-verify.go b/internal/router/api-v1-email-verify.go
new file mode 100644
index 0000000..7fbfdec
--- /dev/null
+++ b/internal/router/api-v1-email-verify.go
@@ -0,0 +1,52 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Email_Verify(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 2 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
+ }
+
+ lang := pathParts[0]
+
+ verificationCode := c.FormValue("email_code")
+ if verificationCode == "" {
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationEmpty, map[string]string{})
+ }
+
+ if err = Mailer.Verify(verificationCode, lang); err != nil {
+ slog.Error("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationFailed, map[string]string{})
+ }
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.VerificationSuccess+"\n\n"+l[lang].UserProfile.RefreshPage, map[string]string{
+ "hide-verification-panel": "true",
+ })
+ }
+}
diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go
new file mode 100644
index 0000000..b5567b7
--- /dev/null
+++ b/internal/router/api-v1-general-page-body.go
@@ -0,0 +1,243 @@
+package router
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "math/rand"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+ "github.com/yuin/goldmark"
+)
+
+var FactGiver *factgiver.FactGiver
+var Mailer *mailer.Mailer
+var BlogTrigger *blogtrigger.BlogTriggerScheduler
+
+func init() {
+ assert(0, tm.Add("general-page-body", "views/partials/general-page-body.html"))
+}
+
+func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ trimmedPath := strings.Trim(path, "/")
+
+ cacheKey := fmt.Sprintf("body.%s", trimmedPath)
+ if val, ok := PCache.Get(cacheKey); !strings.HasSuffix(trimmedPath, "user") && val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(trimmedPath, "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ querySort := c.Query("sort")
+ if querySort == "" {
+ querySort = "publicationDateDesc"
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ re, err := regexp.Compile(`tags\[\]=([\w]+)`)
+ if err != nil {
+ slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
+ }
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := re.FindAllStringSubmatch(decodedQuery, -1)
+
+ queryTags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ queryTags = append(queryTags, string(match[1]))
+ }
+
+ tagsArray, err := getTags(b2Client, lang)
+ if err != nil {
+ slog.Warn("failed to get the available tags", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags")
+ }
+
+ values["Tags"] = tagsArray
+ values["QuerySort"] = querySort
+ values["QueryTags"] = strings.Join(queryTags, ",")
+ values["Title"] = l[lang].BlogSearch.Header
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ values["Title"] = l[lang].UserProfile.Header
+
+ email, _, err := Mailer.GetInfo(Mailer.GetHash(c.IP()))
+ if err != nil {
+ slog.Error("get info from mailer about a client", slog.String("error", err.Error()))
+ }
+
+ tagsArray, err := getTags(b2Client, lang)
+ if err != nil {
+ slog.Warn("failed to get the available tags", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags")
+ }
+
+ subscriptionType, tags, err := Mailer.GetSubscriptions(c.IP())
+ if err != nil {
+ slog.Warn("failed to get the user subscriptions", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to get the user subscriptions")
+ }
+
+ switch subscriptionType {
+ case mailer.None:
+ values["TagsPicked"] = "none"
+ case mailer.All:
+ values["TagsPicked"] = "all"
+ case mailer.Specific:
+ values["TagsPicked"] = "specific"
+ }
+
+ values["TagsPickedList"] = tags
+
+ values["Email"] = email
+ values["EmailCode"] = c.Query("email_code")
+ values["ExistingTags"] = tagsArray
+
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2]))
+ }
+
+ geolocationParts := strings.Split(metadata.Geolocation, " ")
+ var x, y, areaError string
+ if len(geolocationParts) >= 2 {
+ x = geolocationParts[0]
+ y = geolocationParts[1]
+ }
+ if len(geolocationParts) >= 3 {
+ areaError = geolocationParts[2]
+ }
+
+ values["MapLocationX"] = x
+ values["MapLocationY"] = y
+ values["MapLocationAreaMeters"] = areaError
+ values["Title"] = metadata.Title
+ values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+
+ go CCache.View(c.IP(), pathParts[2])
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ values["FilledHeartCount"] = uint(40)
+ values["OutlineHeartCount"] = uint(40)
+ values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
+ values["FunFacts"] = FactGiver.Give(lang)
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ values["Title"] = "Choose Your Language"
+ values["AvailableLanguages"] = langs
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-body", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
+
+func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
+ metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := md.Convert(markdown, &buf); err != nil {
+ return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
+ }
+
+ return metadata, buf.String(), nil
+}
+
+type Tag struct {
+ Name string `json:"Name" yaml:"name"`
+ Count int `json:"Count" yaml:"count"`
+}
+
+func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) {
+ pages, err := b2Client.Scan(lang + "/")
+ if err != nil {
+ slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
+ return nil, fmt.Errorf("failed to scan pages via b2: %w", err)
+ }
+
+ tagsMap := make(map[string]int)
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ tagsMap[tag]++
+ }
+ }
+ slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("lang", lang))
+
+ tagsArray := make([]Tag, 0, len(tagsMap))
+ for tag, count := range tagsMap {
+ tagsArray = append(tagsArray, Tag{tag, count})
+ }
+ slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ return tagsArray, nil
+}
diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go
new file mode 100644
index 0000000..b408187
--- /dev/null
+++ b/internal/router/api-v1-general-page-bottom-embeds.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html"))
+}
+
+func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ cacheKey := fmt.Sprintf("bottom-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
new file mode 100644
index 0000000..99d899a
--- /dev/null
+++ b/internal/router/api-v1-general-page-footer.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-footer", "views/partials/general-page-footer.html"))
+}
+
+func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+
+ cacheKey := fmt.Sprintf("footer.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-footer", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
new file mode 100644
index 0000000..768417d
--- /dev/null
+++ b/internal/router/api-v1-general-page-header.go
@@ -0,0 +1,98 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-header", "views/partials/general-page-header.html"))
+}
+
+func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+
+ cacheKey := fmt.Sprintf("header.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ values["Title"] = l[lang].BlogSearch.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ values["Title"] = l[lang].UserProfile.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path))
+ }
+ values["Title"] = metadata.Title
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ values["Title"] = "Choose Your Language"
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-header", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
new file mode 100644
index 0000000..f3e480b
--- /dev/null
+++ b/internal/router/api-v1-general-page-top-embeds.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html"))
+}
+
+func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+
+ cacheKey := fmt.Sprintf("top-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ } else if len(pathParts) == 0 {
+ additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
+ }
+
+ content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
new file mode 100644
index 0000000..c8b4362
--- /dev/null
+++ b/internal/router/api-v1-general-page.go
@@ -0,0 +1,59 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("general-page", "views/layouts/general-page.html"))
+}
+
+func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ path := c.Path()
+
+ lang := ""
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ langIsAvailable:
+ queryString := string(c.Request().URI().QueryString())
+ cacheKey := fmt.Sprintf("general-page.%s?%s", lang, queryString)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ content, err := tm.Render("general-page", fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": queryString,
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.Set(cacheKey, content, int64(len(content)))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go
new file mode 100644
index 0000000..323c457
--- /dev/null
+++ b/internal/router/api-v1-like.go
@@ -0,0 +1,122 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html"))
+}
+
+func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 3 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
+ }
+
+ lang, page := pathParts[0], pathParts[2]
+
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
+ }
+
+ newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value")
+ }
+
+ ip := c.IP()
+ if newLikeStatus {
+ CCache.LikeOn(ip, page)
+ } else {
+ CCache.LikeOff(ip, page)
+ }
+
+ slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus)))
+ if c.Get("HX-Request", "false") == "true" {
+ content, err := tm.Render("blog-page-like-button", fiber.Map{
+ "L": l[lang],
+ "Liked": newLikeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Send(content)
+ }
+
+ return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus))
+ }
+}
+
+func Api_V1_Like_Get(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 3 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
+ }
+
+ lang, page := pathParts[0], pathParts[2]
+
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
+ }
+
+ ip := c.IP()
+ likeStatus := CCache.GetLikeStatus(ip, page)
+
+ slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
+ if c.Get("HX-Request", "false") == "true" {
+ content, err := tm.Render("blog-page-like-button", fiber.Map{
+ "L": l[lang],
+ "Liked": likeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Send(content)
+ }
+
+ return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus))
+ }
+}
diff --git a/internal/router/api-v1-subs.go b/internal/router/api-v1-subs.go
new file mode 100644
index 0000000..72f16e0
--- /dev/null
+++ b/internal/router/api-v1-subs.go
@@ -0,0 +1,67 @@
+package router
+
+import (
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Subs_Put(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ id := c.IP()
+ lang := "en"
+
+ var referer, path string
+ var pathParts []string
+ var urlStruct *url.URL
+ var err error
+
+ referer = c.Get("Referer", "")
+ if referer == "" {
+ goto skipFetchingLang
+ }
+
+ urlStruct, err = url.ParseRequestURI(referer)
+ if err != nil {
+ goto skipFetchingLang
+ }
+
+ path = urlStruct.EscapedPath()
+ pathParts = strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ goto skipFetchingLang
+ }
+
+ lang = pathParts[0]
+
+ skipFetchingLang:
+ subscriptionType := c.FormValue("tags")
+ var subscriptionTypeEnum mailer.SubscriptionType
+ switch subscriptionType {
+ case "all":
+ subscriptionTypeEnum = mailer.All
+ case "none":
+ subscriptionTypeEnum = mailer.None
+ case "specific":
+ subscriptionTypeEnum = mailer.Specific
+ default:
+ return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.SubscribeInvalidType, map[string]string{})
+ }
+
+ specificTags := c.FormValue("tags_picked")
+ if err = Mailer.Subscribe(Mailer.GetHash(id), subscriptionTypeEnum, specificTags); err != nil {
+ return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.FailedToSubscribe, map[string]string{})
+ }
+
+ return api_v1_email_sendStatusHtml(c, "subs-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.SubscribedSuccessfully, map[string]string{})
+ }
+}
diff --git a/internal/router/api-v1-tz.go b/internal/router/api-v1-tz.go
new file mode 100644
index 0000000..4e4e462
--- /dev/null
+++ b/internal/router/api-v1-tz.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Api_V1_TZ() func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ timestampString := c.Query("timestamp")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ var format string
+ var timestamp time.Time
+ for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
+ if timestamp, err = time.Parse(format, timestampString); err == nil {
+ break
+ }
+ }
+
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format")
+ }
+
+ return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format))
+ }
+}
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
new file mode 100644
index 0000000..e7cda30
--- /dev/null
+++ b/internal/router/client-cache.go
@@ -0,0 +1,320 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/argon2"
+)
+
+type ClientCache struct {
+ hashMap map[string]string
+ hashMapMutex sync.RWMutex
+
+ likePageMap map[string]map[string]struct{}
+ viewPageMap map[string]map[string]struct{}
+ pageMutexMap map[string]*sync.RWMutex
+ pageMutexMapMutex sync.Mutex
+
+ salt []byte
+ db *sql.DB
+}
+
+var CCache *ClientCache
+
+func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
+ tx, err := db.Begin()
+ if err != nil {
+ return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err)
+ }
+ slog.Debug("began db transaction", slog.String("method", "NewClientCache"))
+
+ rows, err := tx.Query("select * from blog_likes;")
+ if err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+ return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err)
+ }
+
+ likePageMap := make(map[string]map[string]struct{})
+ viewPageMap := make(map[string]map[string]struct{})
+ pageMutexMap := make(map[string]*sync.RWMutex)
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := likePageMap[pageRef]; !ok {
+ likePageMap[pageRef] = make(map[string]struct{})
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ likePageMap[pageRef][userIdString] = struct{}{}
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ rows, err = tx.Query("select * from blog_views;")
+ if err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+ return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err)
+ }
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_views to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := viewPageMap[pageRef]; !ok {
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+ return nil, fmt.Errorf("fail to commit transaction in db: %w", err)
+ }
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+
+ return &ClientCache{
+ hashMap: make(map[string]string),
+ likePageMap: likePageMap,
+ viewPageMap: viewPageMap,
+ pageMutexMap: pageMutexMap,
+ salt: salt,
+ db: db,
+ }, nil
+}
+
+func (c *ClientCache) Close() error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return fmt.Errorf("fail to init transaction with db to dump cache: %w", err)
+ }
+ slog.Debug("began db transaction in ClientCache.Close")
+
+ if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return fmt.Errorf("fail to save blog_likes: %s", err)
+ }
+
+ if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return fmt.Errorf("fail to save blog_views: %s", err)
+ }
+
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return fmt.Errorf("fail to commit all the changes related to cache: %s", err)
+ }
+
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return nil
+}
+
+func (c *ClientCache) GetHash(id string) string {
+ c.hashMapMutex.RLock()
+ if val, ok := c.hashMap[id]; ok {
+ c.hashMapMutex.RUnlock()
+ slog.Debug("gave an old hash", slog.String("hash", val))
+ return val
+ }
+ c.hashMapMutex.RUnlock()
+
+ c.hashMapMutex.Lock()
+ defer c.hashMapMutex.Unlock()
+
+ if val, ok := c.hashMap[id]; ok {
+ slog.Debug("gave a newly generated hash", slog.String("hash", val))
+ return val
+ }
+
+ c.hashMap[id] = base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32))
+ slog.Debug("generated hash", slog.String("hash", c.hashMap[id]))
+ return c.hashMap[id]
+}
+
+func (c *ClientCache) getPageMutex(page string) *sync.RWMutex {
+ c.pageMutexMapMutex.Lock()
+ defer c.pageMutexMapMutex.Unlock()
+
+ if mutex, ok := c.pageMutexMap[page]; ok {
+ return mutex
+ }
+
+ c.pageMutexMap[page] = &sync.RWMutex{}
+ return c.pageMutexMap[page]
+}
+
+func (c *ClientCache) GetLikeStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.likePageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetLikeCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ _, alreadyLiked = userSet[hash]
+ c.likePageMap[page][hash] = struct{}{}
+ return
+ }
+
+ c.likePageMap[page] = make(map[string]struct{})
+ c.likePageMap[page][hash] = struct{}{}
+ return
+}
+
+func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return true
+ }
+ if _, ok := c.likePageMap[page][hash]; !ok {
+ return true
+ }
+
+ delete(c.likePageMap[page], hash)
+ return false
+}
+
+func (c *ClientCache) GetViewStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.viewPageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetViewCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.viewPageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) View(id string, page string) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ c.viewPageMap[page] = make(map[string]struct{})
+ }
+ c.viewPageMap[page][hash] = struct{}{}
+}
+
+func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) {
+ if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil {
+ return fmt.Errorf("fail to truncate table %s: %w", table, err)
+ }
+
+ userIdBytes := make(map[string][]byte)
+
+ sqlStatement := fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", 99))
+ sqlStatementVars := make([]any, 0, 200)
+
+ for pageRef, userSet := range pageMap {
+ for userId := range userSet {
+ if _, ok := userIdBytes[userId]; !ok {
+ userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId)
+ if err != nil {
+ slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error()))
+ continue
+ }
+ }
+
+ sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId]))
+ if len(sqlStatementVars) < 200 {
+ continue
+ }
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+
+ sqlStatementVars = make([]any, 0, 200)
+ }
+ }
+
+ if len(sqlStatementVars) > 0 {
+ sqlStatement = fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+ }
+
+ return nil
+}
diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go
new file mode 100644
index 0000000..619b05b
--- /dev/null
+++ b/internal/router/lang-map.go
@@ -0,0 +1,88 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("global-map", "views/pages/global-map.html"))
+}
+
+func Lang_Map(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ lang := c.Params("lang")
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
+
+ langIsAvailable:
+ pages, err := b2Client.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ status = fiber.StatusPartialContent
+ pages = []*b2.BlogPage{}
+ }
+
+ type MapMarker struct {
+ Title string `json:"Title"`
+ PageLink string `json:"PageLink"`
+ Lat float64 `json:"Lat"`
+ Long float64 `json:"Long"`
+ AccuracyMeters int64 `json:"AccuracyMeters"`
+ Thumbnail string `json:"Thumbnail"`
+ }
+
+ mapMarkers := make([]*MapMarker, 0, len(pages))
+ for _, page := range pages {
+ geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
+ if len(geolocationParts) < 2 {
+ continue
+ }
+
+ var x, y float64
+ var areaError int64
+ if len(geolocationParts) >= 2 {
+ x, _ = strconv.ParseFloat(geolocationParts[0], 64)
+ y, _ = strconv.ParseFloat(geolocationParts[1], 64)
+ }
+ if len(geolocationParts) >= 3 {
+ areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
+ }
+
+ mapMarkers = append(mapMarkers, &MapMarker{
+ Title: page.Metadata.Title,
+ PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
+ Lat: x,
+ Long: y,
+ AccuracyMeters: areaError,
+ Thumbnail: page.Metadata.Thumbnail,
+ })
+ }
+
+ content, err := tm.Render("global-map", fiber.Map{
+ "Lang": lang,
+ "L": l[lang],
+ "MapLocationLat": 45.4507,
+ "MapLocationLong": 68.8319,
+ "MapMarkers": mapMarkers,
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
+ }
+
+ return c.Type("html").Status(status).Send(content)
+ }
+}
diff --git a/internal/router/lang-user-unsubscribe.go b/internal/router/lang-user-unsubscribe.go
new file mode 100644
index 0000000..c39f5e7
--- /dev/null
+++ b/internal/router/lang-user-unsubscribe.go
@@ -0,0 +1,71 @@
+package router
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("unsubscribe-page", "views/pages/unsubscribe-page.html"))
+}
+
+func Lang_User_Unsubscribe(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ lang := c.Params("lang")
+ for _, availableLang := range langs {
+ if availableLang.Name == lang {
+ goto langIsAvailable
+ }
+ }
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
+
+ langIsAvailable:
+ var statusEmoji, statusText, statusColor string
+ var status int
+
+ unsubscribeCode := c.FormValue("code")
+ if unsubscribeCode == "" {
+ statusColor = "0, 0, 255"
+ statusEmoji = "(╭ರ_•́)"
+ statusText = l[lang].UnsubscribePage.UnsetCode
+ status = fiber.ErrBadRequest.Code
+ } else if clientError, serverError := Mailer.Unsubscribe(unsubscribeCode); clientError != nil {
+ slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error()))
+ statusColor = "255, 0, 0"
+ statusEmoji = "(͠≖~≖ ͡ )"
+ statusText = l[lang].UnsubscribePage.InvalidCode
+ status = fiber.ErrBadRequest.Code
+ } else if serverError != nil {
+ slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error()))
+ statusColor = "255, 128, 0"
+ statusEmoji = "( ˶°ㅁ°) !!"
+ statusText = l[lang].UnsubscribePage.OnServerError
+ status = fiber.ErrInternalServerError.Code
+ } else {
+ statusColor = "0, 255, 0"
+ statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
+ statusText = l[lang].UnsubscribePage.Success
+ status = fiber.StatusOK
+ }
+
+ content, err := tm.Render("unsubscribe-page", fiber.Map{
+ "Lang": lang,
+ "L": l[lang],
+ "StatusEmoji": statusEmoji,
+ "StatusText": statusText,
+ "StatusColor": template.HTML(statusColor),
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/user/unsubscribe"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
+ }
+
+ return c.Type("html").Status(status).Send(content)
+ }
+}
diff --git a/internal/router/page-cache.go b/internal/router/page-cache.go
new file mode 100644
index 0000000..01e4792
--- /dev/null
+++ b/internal/router/page-cache.go
@@ -0,0 +1,5 @@
+package router
+
+import "github.com/dgraph-io/ristretto/v2"
+
+var PCache *ristretto.Cache[string, []byte]
diff --git a/internal/router/tm.go b/internal/router/tm.go
new file mode 100644
index 0000000..e8095ee
--- /dev/null
+++ b/internal/router/tm.go
@@ -0,0 +1,18 @@
+package router
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+)
+
+var tm = assert(templatemanager.NewTemplateManager())
+
+func assert[T any](t T, err error) T {
+ if err != nil {
+ slog.Error("fail to initialize template manager", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ return t
+}
diff --git a/internal/tailwind/link_renderer.go b/internal/tailwind/link_renderer.go
new file mode 100644
index 0000000..ff3d437
--- /dev/null
+++ b/internal/tailwind/link_renderer.go
@@ -0,0 +1,49 @@
+package tailwind
+
+import (
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+type CustomLinkRenderer struct {
+ html.Config
+}
+
+func NewCustomLinkRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &CustomLinkRenderer{
+ Config: html.NewConfig(),
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ return r
+}
+
+func (r *CustomLinkRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ n := node.(*ast.Link)
+ if entering {
+ _, _ = w.WriteString("<a href=\"")
+ if r.Unsafe || !html.IsDangerousURL(n.Destination) {
+ _, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
+ }
+ _, _ = w.WriteString(`"`)
+ if n.Title != nil {
+ _, _ = w.WriteString(` title="`)
+ _, _ = w.Write(util.EscapeHTML(n.Title))
+ _, _ = w.WriteString(`"`)
+ }
+ if n.Attributes() != nil {
+ html.RenderAttributes(w, n, nil)
+ }
+ _, _ = w.WriteString(">")
+ } else {
+ _, _ = w.WriteString("</a>")
+ }
+ return ast.WalkContinue, nil
+}
+
+func (r *CustomLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindLink, r.renderLink)
+}
diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go
index 51a33bc..c53ded1 100644
--- a/internal/tailwind/transformer.go
+++ b/internal/tailwind/transformer.go
@@ -1,6 +1,9 @@
package tailwind
import (
+ "bytes"
+ "fmt"
+
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
@@ -17,44 +20,48 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader,
switch node := n.(type) {
case *ast.Heading:
classes := map[int]string{
- 1: "font-patua text-vmax2-5 font-bold text-main-dark mb-vmin1-2 ls-vmin0-04",
- 2: "font-spectral text-vmax2 font-bold text-main-dark mb-vmin0-8 ls-vmin0-04",
- 3: "font-spectral text-vmax1-5 font-medium text-main-dark mb-vmin0-6 ls-vmin0-04",
- 4: "font-spectral text-vmax1-2 font-medium text-main-medium mb-vmin0-4 ls-vmin0-04",
- 5: "font-spectral text-vmax1 font-medium text-main-medium mb-vmin0-4 italic ls-vmin0-04",
- 6: "font-spectral text-vmax1 font-medium text-secondary mb-vmin0-4",
+ 1: "font-andika text-4xl font-bold text-main-hard mb-3 tracking-[.0125rem]",
+ 2: "font-andika text-3xl font-bold text-main-hard mb-1 tracking-[.0125rem]",
+ 3: "font-andika text-2xl font-medium text-main-hard mb-0.8 tracking-[.0125rem]",
+ 4: "font-andika text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]",
+ 5: "font-andika text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]",
+ 6: "font-andika text-base font-medium text-secondary mb-0.5",
}
if class, ok := classes[node.Level]; ok {
node.SetAttribute([]byte("class"), []byte(class))
}
case *ast.Paragraph:
- node.SetAttribute([]byte("class"), []byte("text-vmax1 font-spectral lh-2 ls-vmin0-04 timl-vmax2 mb-vmin1-6"))
+ node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem] -indent-8 ml-4 mb-8"))
case *ast.List:
if node.IsOrdered() {
- node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-vmin0-8 mb-vmin1-6 pl-vmax2"))
+ node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-2 mb-4 pl-8"))
} else {
- node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-vmin0-8 mb-vmin1-6 pl-vmax2"))
+ node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-2 mb-4 pl-8"))
}
case *ast.ListItem:
- node.SetAttribute([]byte("class"), []byte("text-vmax1 font-spectral lh-2 ls-vmin0-04"))
+ node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem]"))
case *ast.Blockquote:
- node.SetAttribute([]byte("class"), []byte("border-l-vmin0-4 border-main-medium bg-background-dark p-vmin0-8 mb-vmin0-8 italic"))
+ node.SetAttribute([]byte("class"), []byte("border-l-2 border-main-medium bg-background-dark p-1 mb-2 italic"))
case *ast.CodeSpan:
node.SetAttribute([]byte("class"), []byte("bg-background-dark"))
case *ast.CodeBlock, *ast.FencedCodeBlock:
- node.SetAttribute([]byte("class"), []byte("bg-background-dark p-vmin0-8 rounded-lg overflow-x-auto mb-vmin1-6"))
+ node.SetAttribute([]byte("class"), []byte("bg-background-dark p-1 rounded-lg overflow-x-auto mb-4"))
case *ast.Link:
- node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline"))
+ if bytes.HasPrefix(node.Destination, []byte{'.', '/'}) {
+ onclickAttr := fmt.Appendf(make([]byte, 0, 21+len(node.Destination)), "return changeUrl('%s');", node.Destination)
+ node.SetAttribute([]byte("onclick"), onclickAttr)
+ }
+ node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-hard underline"))
case *ast.Image:
- node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-vmin1-6"))
+ node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-4"))
case *ast.Emphasis:
switch node.Level {
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
new file mode 100644
index 0000000..bafdee5
--- /dev/null
+++ b/internal/templatemanager/templatemanager.go
@@ -0,0 +1,100 @@
+package templatemanager
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "path/filepath"
+ "strings"
+)
+
+type TemplateManager struct {
+ templates map[string]templateManagerRender
+}
+
+type templateManagerRender struct {
+ Main string
+ Tmpl *template.Template
+}
+
+type TemplateManagerTemplates struct {
+ Name string
+ Files []string
+}
+
+var templateFuncMap = template.FuncMap{
+ "contains": strings.Contains,
+ "iterate": func(count uint) []uint {
+ items := make([]uint, count)
+ for i := range count {
+ items[i] = i
+ }
+ return items
+ },
+ "replace": strings.ReplaceAll,
+}
+
+func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) {
+ templateMap := make(map[string]templateManagerRender)
+
+ for _, tmplStruct := range templates {
+ tmpl := template.New(tmplStruct.Name).Funcs(templateFuncMap)
+ tmpl, err := tmpl.ParseFiles(tmplStruct.Files...)
+ if err != nil {
+ return nil, err
+ }
+ templateMap[tmplStruct.Name] = templateManagerRender{
+ Main: filepath.Base(tmplStruct.Files[0]),
+ Tmpl: tmpl,
+ }
+ }
+
+ return &TemplateManager{
+ templates: templateMap,
+ }, nil
+}
+
+func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) {
+ tmpl, exists := tm.templates[name]
+ if !exists {
+ return nil, fmt.Errorf("template %s is not found", name)
+ }
+
+ var err error
+ var tempTmpl *template.Template
+ if len(files) == 0 {
+ tempTmpl = tmpl.Tmpl
+ } else {
+ tempTmpl, err = tmpl.Tmpl.Clone()
+ if err != nil {
+ return nil, fmt.Errorf("couldn't clone existing template for rendering: %w", err)
+ }
+ tempTmpl, err = tempTmpl.ParseFiles(files...)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't include additional files in template rendering: %w", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data)
+ return buf.Bytes(), err
+}
+
+func (tm *TemplateManager) Add(name string, files ...string) error {
+ if len(files) == 0 {
+ return fmt.Errorf("you can't add template without any files")
+ }
+
+ tmpl := template.New(name).Funcs(templateFuncMap)
+
+ tmpl, err := tmpl.ParseFiles(files...)
+ if err != nil {
+ return fmt.Errorf("failed to add template into manager: %w", err)
+ }
+
+ tm.templates[name] = templateManagerRender{
+ Main: filepath.Base(files[0]),
+ Tmpl: tmpl,
+ }
+ return nil
+}
diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml
new file mode 100644
index 0000000..c7f2df8
--- /dev/null
+++ b/locale/localization.en.yaml
@@ -0,0 +1,74 @@
+TagsLabel: 'Tags:'
+BlogSearch:
+ Header: 'Blog Search'
+ TagsHeader: 'Tags'
+ OrderByHeader: 'Order by...'
+ TitleOrdered: 'Title'
+ ActionDateOrdered: 'Action Date'
+ PublicationDateOrdered: 'Publication Date'
+ ChooseAllTags: 'Choose All'
+GlobalMap:
+ Header: 'Global Map'
+HomePage:
+ Header: 'Home Page'
+ Contacts: 'Contacts'
+ DidYouKnowThat: 'Did you know, that...'
+ SidebarDescription: 'Describing the Sidebar'
+ HomePageDescription: 'Home Page -- where we are right now!'
+ BlogSearchDescription: 'Blog Search. You can find my travel notes there and, maybe, something else!'
+ MarkerMapDescription: 'Map with markers of my travel notes -- a convinient way to find an interesting region and find related posts!'
+ ThemeSwitchDescription: 'Theme Palette lets you change the theme of the site -- there are 2 dark and 2 bright themes currently!'
+ Hymn1: 'We welcome you to Sayasite!'
+ Hymn2: "We've been expecting you!"
+ Hymn3: 'You bring such joy in Sayasite!'
+ Hymn4: 'No matter where you roam, know our love is true!~'
+UnsubscribePage:
+ Header: 'Cancelling the Subscription'
+ UnsetCode: 'Your cancel code is not set. You just having a walk around the site?'
+ InvalidCode: "Your cancel code could not be verified. Maybe, the code expired after a day. Or maybe, you've already unsubscribed."
+ OnServerError: "We couldn't unsubscribe you, the problem is on our side! We will solve this problem as soon as possible."
+ Success: 'Cancelled your subscription successfully! We do hope we will engage you again someday.'
+Metadata:
+ Published: 'Published'
+ Action: 'Took place on'
+Mail:
+ UnsubscribeFooter: 'If this letter got you in a bad mood, you can unsubscribe from my blog by {}this link{/}.'
+ VerifyEmail:
+ Subject: 'Verification Link for SAYA.TODAY is waiting for you!'
+ Welcome: 'Greetings!'
+ Intro: 'There are 2 ways for us to validate your profile:'
+ GotoLink: 'Go by this link:'
+ InputCode: "Or input this code on the personal page:"
+ IfRandom: 'If you are unsure what this letter this, you can ignore it, and also, if you are interested, you may visit my site :)'
+ NewPost:
+ Subject: 'A new post arrived at SAYA.TODAY!'
+ Intro: 'A new post came!'
+ CapturedOn: 'Captured on'
+UserProfile:
+ Header: 'Personal Settings'
+ EmailHeader: 'E-Mail'
+ VerificationCodeHeader: 'Verification Code'
+ TypeEmail: 'Please type your email'
+ SendCodeButton: 'Send Code'
+ VerifyButton: 'Verify'
+ VerificationCodeSent: 'Verification code sent! Please check your e-mail. Your next verification attempt will be possible on {}.'
+ DelayTilVerification: 'You have already requested the verification before! Please wait until {} for a new verification request.'
+ FailedEmailRender: 'Failed to render the email status message, please ask administrator for a more detailed cause.'
+ VerificationCodeSendingError: 'Failed to send a verification code, please ask administrator for a more detailed cause.'
+ EmailAlreadyValidated: 'You already have this E-Mail validated.'
+ EmailTaken: 'This E-Mail is already taken.'
+ EmailEmpty: 'E-Mail is unset or empty.'
+ VerificationSuccess: 'Success! You are now able to subscribe with your new e-mail.'
+ VerificationFailed: 'Verification code is invalid, please ask administrator for a more detailed cause.'
+ VerificationEmpty: 'Verification code is unset or empty.'
+ SubscriptionHeader: 'Subscribe to...'
+ TagsNone: 'None'
+ TagsAll: 'All'
+ TagsSpecific: 'Specific'
+ TagDoesNotExist: 'The tag "{}" does not exist on the site.'
+ TagAlreadyAdded: 'The tag "{}" is already in your subscribed list.'
+ SaveButton: 'Save'
+ SubscribeInvalidType: 'Invalid subscription type.'
+ FailedToSubscribe: 'Failed to subscribe by new settings, please ask administrator for a more detailed cause.'
+ SubscribedSuccessfully: 'Successfully subscribed you by new settings!'
+ RefreshPage: 'Please refresh the profile page on the device, where you requested the code.'
diff --git a/locale/localization.go b/locale/localization.go
new file mode 100644
index 0000000..c6467c4
--- /dev/null
+++ b/locale/localization.go
@@ -0,0 +1,139 @@
+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"`
+ 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"`
+}
+
+type HomePageConfig struct {
+ Header string `yaml:"Header" json:"Header"`
+ Contacts string `yaml:"Contacts" json:"Contacts"`
+ DidYouKnowThat string `yaml:"DidYouKnowThat" json:"DidYouKnowThat"`
+ SidebarDescription string `yaml:"SidebarDescription" json:"SidebarDescription"`
+ HomePageDescription string `yaml:"HomePageDescription" json:"HomePageDescription"`
+ BlogSearchDescription string `yaml:"BlogSearchDescription" json:"BlogSearchDescription"`
+ MarkerMapDescription string `yaml:"MarkerMapDescription" json:"MarkerMapDescription"`
+ ThemeSwitchDescription string `yaml:"ThemeSwitchDescription" json:"ThemeSwitchDescription"`
+ Hymn1 string `yaml:"Hymn1" json:"Hymn1"`
+ Hymn2 string `yaml:"Hymn2" json:"Hymn2"`
+ Hymn3 string `yaml:"Hymn3" json:"Hymn3"`
+ Hymn4 string `yaml:"Hymn4" json:"Hymn4"`
+}
+
+type UnsubscribePageConfig struct {
+ Header string `yaml:"Header" json:"Header"`
+ UnsetCode string `yaml:"UnsetCode" json:"UnsetCode"`
+ InvalidCode string `yaml:"InvalidCode" json:"InvalidCode"`
+ OnServerError string `yaml:"OnServerError" json:"OnServerError"`
+ Success string `yaml:"Success" json:"Success"`
+}
+
+type MetadataConfig struct {
+ Action string `yaml:"Action" json:"Action"`
+ Published string `yaml:"Published" json:"Published"`
+}
+
+type MailConfig struct {
+ UnsubscribeFooter string `yaml:"UnsubscribeFooter" json:"UnsubscribeFooter"`
+ VerifyEmail VerifyEmailConfig `yaml:"VerifyEmail" json:"VerifyEmail"`
+ NewPost NewPostConfig `yaml:"NewPost" json:"NewPost"`
+}
+
+type VerifyEmailConfig struct {
+ Subject string `yaml:"Subject" json:"Subject"`
+ Welcome string `yaml:"Welcome" json:"Welcome"`
+ Intro string `yaml:"Intro" json:"Intro"`
+ GotoLink string `yaml:"GotoLink" json:"GotoLink"`
+ InputCode string `yaml:"InputCode" json:"InputCode"`
+ IfRandom string `yaml:"IfRandom" json:"IfRandom"`
+}
+
+type NewPostConfig struct {
+ Subject string `yaml:"Subject" json:"Subject"`
+ CapturedOn string `yaml:"CapturedOn" json:"CapturedOn"`
+ Intro string `yaml:"Intro" json:"Intro"`
+}
+
+type UserProfileConfig struct {
+ Header string `yaml:"Header" json:"Header"`
+ EmailHeader string `yaml:"EmailHeader" json:"EmailHeader"`
+ VerificationCodeHeader string `yaml:"VerificationCodeHeader" json:"VerificationCodeHeader"`
+ TypeEmail string `yaml:"TypeEmail" json:"TypeEmail"`
+ SendCodeButton string `yaml:"SendCodeButton" json:"SendCodeButton"`
+ VerifyButton string `yaml:"VerifyButton" json:"VerifyButton"`
+ VerificationCodeSent string `yaml:"VerificationCodeSent" json:"VerificationCodeSent"`
+ DelayTilVerification string `yaml:"DelayTilVerification" json:"DelayTilVerification"`
+ FailedEmailRender string `yaml:"FailedEmailRender" json:"FailedEmailRender"`
+ VerificationCodeSendingError string `yaml:"VerificationCodeSendingError" json:"VerificationCodeSendingError"`
+ EmailAlreadyValidated string `yaml:"EmailAlreadyValidated" json:"EmailAlreadyValidated"`
+ EmailTaken string `yaml:"EmailTaken" json:"EmailTaken"`
+ EmailEmpty string `yaml:"EmailEmpty" json:"EmailEmpty"`
+ VerificationSuccess string `yaml:"VerificationSuccess" json:"VerificationSuccess"`
+ VerificationFailed string `yaml:"VerificationFailed" json:"VerificationFailed"`
+ VerificationEmpty string `yaml:"VerificationEmpty" json:"VerificationEmpty"`
+ SubscriptionHeader string `yaml:"SubscriptionHeader" json:"SubscriptionHeader"`
+ TagsNone string `yaml:"TagsNone" json:"TagsNone"`
+ TagsAll string `yaml:"TagsAll" json:"TagsAll"`
+ TagsSpecific string `yaml:"TagsSpecific" json:"TagsSpecific"`
+ TagDoesNotExist string `yaml:"TagDoesNotExist" json:"TagDoesNotExist"`
+ TagAlreadyAdded string `yaml:"TagAlreadyAdded" json:"TagAlreadyAdded"`
+ SaveButton string `yaml:"SaveButton" json:"SaveButton"`
+ SubscribeInvalidType string `yaml:"SubscribeInvalidType" json:"SubscribeInvalidType"`
+ FailedToSubscribe string `yaml:"FailedToSubscribe" json:"FailedToSubscribe"`
+ SubscribedSuccessfully string `yaml:"SubscribedSuccessfully" json:"SubscribedSuccessfully"`
+ RefreshPage string `yaml:"RefreshPage" json:"RefreshPage"`
+}
+
+func LoadConfig(path string, config *LocaleConfig) error {
+ fileBytes, err := os.ReadFile(path)
+ if err != nil {
+ 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/locale/localization.ru.yaml b/locale/localization.ru.yaml
new file mode 100644
index 0000000..5df82da
--- /dev/null
+++ b/locale/localization.ru.yaml
@@ -0,0 +1,74 @@
+TagsLabel: 'Тэги:'
+BlogSearch:
+ Header: 'Поиск по блогу'
+ TagsHeader: 'Тэги'
+ OrderByHeader: 'Упорядочить по...'
+ TitleOrdered: 'названию'
+ ActionDateOrdered: 'дате действия'
+ PublicationDateOrdered: 'времени публикации'
+ ChooseAllTags: 'Выбрать все'
+GlobalMap:
+ Header: 'Глобальная карта'
+HomePage:
+ Header: 'Домашняя страница'
+ Contacts: 'Контакты'
+ DidYouKnowThat: 'А вы знали, что...'
+ SidebarDescription: 'Поясняем за сайдбар'
+ HomePageDescription: 'Домашняя страница -- где мы сейчас и находимся!'
+ BlogSearchDescription: 'Поиск по блогу. Там размещены мои путевые заметки и, может, что-то ещё!'
+ MarkerMapDescription: 'Карта с маркерами моих путевых заметок, -- удобный способ найти интересный регион и перейти на заинтересовавшие посты!'
+ ThemeSwitchDescription: 'Позволяет поменять тему сайта -- есть 2 светлые и 2 тёмные темы!'
+ Hymn1: 'Приветствуем Вас на Саясайте!'
+ Hymn2: 'Мы Вас столько ждали!'
+ Hymn3: 'Столько радости от Вас на Саясайте!'
+ Hymn4: 'Где бы Вы не были, знайте, -- наша любовь крепка!'
+UnsubscribePage:
+ Header: 'Отписка'
+ UnsetCode: 'Код для отписки не проставлен. Вы случайно не заблудились?'
+ InvalidCode: 'Код для отписки не подтверждён. Может, истекло время в сутки для кода. А может, вы уже успели отписаться.'
+ OnServerError: 'Мы не смогли вас отписать, проблема на нашей стороне! В скором времени займёмся проблемой.'
+ Success: 'Вы отписались от рассылки! Надеюсь, мы ещё сможем вас заинтересовать.'
+Metadata:
+ Published: 'Опубликовано'
+ Action: 'Время действия'
+Mail:
+ UnsubscribeFooter: 'Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по {}этой ссылке{/}.'
+ VerifyEmail:
+ Subject: 'Ссылка для верификации для подтверждения вашей почты SAYA.TODAY'
+ Welcome: 'Приветствую!'
+ Intro: 'Есть 2 способа, чтобы подтвердить свою электронную почту:'
+ GotoLink: 'Перейти по этой ссылке:'
+ InputCode: 'Либо ввести этот код на странице подписок:'
+ IfRandom: 'Если ты не понимаешь, что это за спам, можешь проигнорировать это письмо, а ещё, если интересно, можешь посетить мой сайт :)'
+ NewPost:
+ Subject: 'Новый пост на SAYA.TODAY!'
+ Intro: 'Вышел новый пост!'
+ CapturedOn: 'Снималось'
+UserProfile:
+ Header: 'Личные настройки'
+ EmailHeader: 'E-Mail'
+ VerificationCodeHeader: 'Код верификации'
+ TypeEmail: 'Пожалуйста, введите свою почту'
+ SendCodeButton: 'Отправить код'
+ VerifyButton: 'Верифицировать'
+ VerificationCodeSent: 'Выслали вам код верификации! Проверьте свою почту. Следующая попытка верификации будет возможна {}.'
+ DelayTilVerification: 'Вы уже запрашивали недавно верификацию! Дождитесь {} для новой попытки.'
+ FailedEmailRender: 'Неудачная попытка создать электронное письмо, более подробно спрашивайте у администратора.'
+ VerificationCodeSendingError: 'Неудачная попытка прислать код для верификации, более подробно спрашивайте у администратора.'
+ EmailAlreadyValidated: 'Эту почту вы уже подтверждали.'
+ EmailTaken: 'Эта почта уже занята.'
+ EmailEmpty: 'Почта не введена.'
+ VerificationSuccess: 'Поздравляем! Теперь по этой почте можно подписаться на блог.'
+ VerificationFailed: 'Код для верификации невалиден, при вопросах, пожалуйста, обращайтесь к администратору.'
+ VerificationEmpty: 'Код для верификации не введён.'
+ SubscriptionHeader: 'Подписаться на...'
+ TagsNone: 'Ничего'
+ TagsAll: 'Всё'
+ TagsSpecific: 'Определённое'
+ TagDoesNotExist: 'Тэга "{}" нет на сайте.'
+ TagAlreadyAdded: 'Тэг "{}" уже у вас добавлен.'
+ SaveButton: 'Сохранить'
+ SubscribeInvalidType: 'Неизвестный тип подписки.'
+ FailedToSubscribe: 'Неудачная попытка записать новую подписку, более подробно спрашивайте у администратора.'
+ SubscribedSuccessfully: 'Вы успешно подписались по новым настройкам!'
+ RefreshPage: 'Пожалуйста, обновите страницу с профилем на устройстве, откуда запрашивали код.'
diff --git a/main.go b/main.go
index 37d6e8d..174854d 100644
--- a/main.go
+++ b/main.go
@@ -1,43 +1,61 @@
package main
import (
- "bytes"
+ "database/sql"
+ "errors"
"flag"
- "fmt"
- "html/template"
- "log"
"log/slog"
"os"
- "strconv"
- "strings"
+ "os/signal"
+ "syscall"
"github.com/SayaAndy/saya-today-web/config"
"github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/frontmatter"
- "github.com/SayaAndy/saya-today-web/internal/lightgallery"
+ "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/internal/glightbox"
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/router"
"github.com/SayaAndy/saya-today-web/internal/tailwind"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/dgraph-io/ristretto/v2"
"github.com/gofiber/fiber/v2"
- "github.com/gofiber/template/html/v2"
+ "github.com/gofiber/fiber/v2/middleware/cors"
+ "github.com/golang-migrate/migrate/v4"
+ "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
- gmhtml "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+
+ _ "github.com/golang-migrate/migrate/v4/source/file"
+ _ "github.com/mattn/go-sqlite3"
)
var (
md = goldmark.New(
goldmark.WithExtensions(
- lightgallery.NewLightGalleryExtension(),
+ glightbox.NewGLightboxExtension(),
tailwind.NewTailwindExtension(),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
+ parser.WithAttribute(),
),
- goldmark.WithRendererOptions(
- gmhtml.WithXHTML(),
+ goldmark.WithRenderer(
+ renderer.NewRenderer(
+ renderer.WithNodeRenderers(
+ util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50),
+ util.Prioritized(html.NewRenderer(html.WithXHTML()), 100),
+ ),
+ ),
),
)
- b2Client *b2.B2Client
- configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)")
+ b2Client *b2.B2Client
+ configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)")
+ availableLanguages = make([]config.AvailableLanguageConfig, 0)
+ localization = make(map[string]*locale.LocaleConfig)
)
func main() {
@@ -54,81 +72,148 @@ func main() {
slog.SetLogLoggerLevel(cfg.LogLevel)
slog.Info("starting sayana-web server...")
+ db, err := sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN)
+ if err != nil {
+ slog.Error("fail to initialize db", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
+ if err != nil {
+ slog.Error("fail to initialize driver for migrating db", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ m, err := migrate.NewWithDatabaseInstance(
+ "file://migrations",
+ cfg.Auth.Db.Type, driver)
+ if err != nil {
+ slog.Error("fail to initialize migration client", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ if err = m.Up(); err != nil && err == errors.New("no change") {
+ slog.Error("fail to apply migrations", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ slog.Info("successfully applied migrations")
+
b2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config)
if err != nil {
slog.Error("fail to initialize b2 client", slog.String("error", err.Error()))
os.Exit(1)
}
- engine := html.New("./views", ".html")
+ for _, lang := range cfg.AvailableLanguages {
+ localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
+ if err != nil {
+ slog.Warn("fail to initialize a locale", slog.String("locale", lang.Name), slog.String("error", err.Error()))
+ continue
+ }
+ availableLanguages = append(availableLanguages, lang)
+ localization[lang.Name] = localeCfg
+ }
app := fiber.New(fiber.Config{
- Views: engine,
+ ProxyHeader: "X-Forwarded-For",
})
- app.Get("/", func(c *fiber.Ctx) error {
- pages, err := b2Client.Scan()
- status := fiber.StatusOK
- if err != nil {
- status = fiber.StatusPartialContent
- pages = []*b2.BlogPage{}
- }
+ app.Use(cors.New(cors.Config{
+ AllowOrigins: "https://f003.backblazeb2.com",
+ AllowMethods: "GET,POST,OPTIONS",
+ AllowHeaders: "Origin, Content-Type, Accept",
+ }))
- pageMeta := make([]map[string]string, 0, len(pages))
- for _, page := range pages {
- slog.Debug("enlist page for catalogue", slog.Any("page", page), slog.String("endpoint", "/"))
- pageMeta = append(pageMeta, map[string]string{
- "Link": page.Link,
- "Title": page.Metadata.Title,
- "PublishedTime": page.Metadata.PublishedTime.Format("2006-01-02 15:04:05 -0700"),
- "ActionDate": page.Metadata.ActionDate,
- "ShortDescription": page.Metadata.ShortDescription,
- "Thumbnail": page.Metadata.Thumbnail,
- "Tags": strings.Join(page.Metadata.Tags, ", "),
- })
- }
+ router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt))
+ if err != nil {
+ slog.Error("fail to initialize client cache", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
- return c.Status(status).Render("index", fiber.Map{
- "PublishedYear": "2025",
- "BlogPages": pageMeta,
- })
+ router.PCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
+ NumCounters: 1e6, // 1,000,000
+ MaxCost: 1 << 29, // 512 MB
+ BufferItems: 64, // number of keys per Get buffer.
})
+ if err != nil {
+ slog.Error("fail to initialize page cache", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
- app.Get("/blog/:lang/:title", func(c *fiber.Ctx) error {
- metadata, parsedMarkdownDesktop, parsedMarkdownMobile, err := readBlogPost(c.Params("lang") + "/" + c.Params("title"))
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title")))
- }
+ router.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, availableLanguages)
+ if err != nil {
+ slog.Error("fail to initialize fact giver", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ router.Mailer, err = mailer.NewMailer(db, cfg.Mail.ClientHost, cfg.Mail.MailHost,
+ cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), localization)
+ if err != nil {
+ slog.Error("fail to initialize mailer", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
- return c.Render("layouts/blog-page", fiber.Map{
- "Title": metadata.Title,
- "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"),
- "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()),
- "ParsedMarkdownDesktop": template.HTML(parsedMarkdownDesktop),
- "ParsedMarkdownMobile": template.HTML(parsedMarkdownMobile),
- })
+ router.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(b2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost, func(bp []*b2.BlogPage) error {
+ for _, post := range bp {
+ if err := router.Mailer.NewPost(post); err != nil {
+ return err
+ }
+ }
+ return nil
})
+ if err != nil {
+ slog.Error("fail to initialize blog trigger", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ app.Get("/", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang<len(2)>", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client))
+ app.Get("/:lang/user", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang/user/unsubscribe", router.Lang_User_Unsubscribe(localization, availableLanguages))
+ app.Get("/:lang/blog", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang/blog/:title", router.Api_V1_GeneralPage(localization, availableLanguages))
+
+ app.Get("/api/v1/tz", router.Api_V1_TZ())
+ app.Get("/api/v1/blog-search", router.Api_V1_BlogSearch(localization, availableLanguages, b2Client))
+ app.Get("/api/v1/like", router.Api_V1_Like_Get(localization, b2Client))
+ app.Put("/api/v1/like", router.Api_V1_Like_Put(localization, b2Client))
+ app.Get("/api/v1/general-page/header", router.Api_V1_GeneralPage_Header(localization, availableLanguages, b2Client))
+ app.Get("/api/v1/general-page/body", router.Api_V1_GeneralPage_Body(localization, availableLanguages, b2Client, md))
+ app.Get("/api/v1/general-page/footer", router.Api_V1_GeneralPage_Footer(localization, availableLanguages))
+ app.Get("/api/v1/general-page/top-embeds", router.Api_V1_GeneralPage_TopEmbeds(localization, availableLanguages))
+ app.Get("/api/v1/general-page/bottom-embeds", router.Api_V1_GeneralPage_BottomEmbeds(localization, availableLanguages, b2Client))
+ app.Post("/api/v1/email/send-verification-code", router.Api_V1_Email_SendVerificationCode(localization))
+ app.Post("/api/v1/email/verify", router.Api_V1_Email_Verify(localization))
+ app.Get("/api/v1/email/is-in-verification", router.Api_V1_Email_IsInVerification(localization))
+ app.Put("/api/v1/subs", router.Api_V1_Subs_Put(localization))
app.Static("/", "./static")
- log.Fatal(app.Listen(":3000"))
-}
+ go func() {
+ if err := app.Listen(":3000"); err != nil {
+ slog.Error("error while running fiber server", slog.String("error", err.Error()))
+ panic(err)
+ }
+ }()
-func readBlogPost(sourceName string) (metadata *frontmatter.Metadata, desktopBody string, mobileBody string, err error) {
- metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
- if err != nil {
- return nil, "", "", fmt.Errorf("failed to read a frontmatter file: %w", err)
- }
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
- var bufDesktop bytes.Buffer
- if err := md.Convert(markdown, &bufDesktop); err != nil {
- return nil, "", "", fmt.Errorf("convert source context from md to html (desktop version): %w", err)
+ <-sigChan
+ slog.Info("gracefully shutting down...")
+ if err = router.BlogTrigger.Close(); err != nil {
+ slog.Error("fail to shutdown blog trigger scheduler", slog.String("error", err.Error()))
}
- var bufMobile bytes.Buffer
- if err := md.Convert(markdown, &bufMobile); err != nil {
- return nil, "", "", fmt.Errorf("convert source context from md to html (mobile version): %w", err)
+ if err = app.Shutdown(); err != nil {
+ slog.Error("fail to shutdown fiber server", slog.String("error", err.Error()))
}
-
- return metadata, bufDesktop.String(), bufMobile.String(), nil
+ if err = router.CCache.Close(); err != nil {
+ slog.Error("fail to dump cache", slog.String("error", err.Error()))
+ }
+ if err = db.Close(); err != nil {
+ slog.Error("fail to close db connection", slog.String("error", err.Error()))
+ }
+ router.PCache.Close()
+ db.Close()
}
diff --git a/migrations/1_create_stats_tables.down.sql b/migrations/1_create_stats_tables.down.sql
new file mode 100644
index 0000000..ee4593c
--- /dev/null
+++ b/migrations/1_create_stats_tables.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS blog_views;
+DROP TABLE IF EXISTS blog_likes;
diff --git a/migrations/1_create_stats_tables.up.sql b/migrations/1_create_stats_tables.up.sql
new file mode 100644
index 0000000..8852268
--- /dev/null
+++ b/migrations/1_create_stats_tables.up.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS blog_likes (
+ page_ref VARCHAR(32) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ PRIMARY KEY (page_ref, user_id)
+) WITHOUT ROWID;
+
+CREATE TABLE IF NOT EXISTS blog_views (
+ page_ref VARCHAR(32) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ PRIMARY KEY (page_ref, user_id)
+) WITHOUT ROWID;
diff --git a/migrations/2_create_subscription_tables.down.sql b/migrations/2_create_subscription_tables.down.sql
new file mode 100644
index 0000000..d9a369e
--- /dev/null
+++ b/migrations/2_create_subscription_tables.down.sql
@@ -0,0 +1,4 @@
+DROP INDEX IF EXISTS user_email_table_email_uindex;
+DROP TABLE IF EXISTS user_email_table;
+DROP TABLE IF EXISTS subscription_user_to_tags_table;
+DROP TABLE IF EXISTS subscription_tag_to_users_table;
diff --git a/migrations/2_create_subscription_tables.up.sql b/migrations/2_create_subscription_tables.up.sql
new file mode 100644
index 0000000..e6e905a
--- /dev/null
+++ b/migrations/2_create_subscription_tables.up.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS user_email_table (
+ user_id VARCHAR(32) NOT NULL PRIMARY KEY,
+ email VARCHAR(64) NOT NULL,
+ lang VARCHAR(2) NOT NULL
+) WITHOUT ROWID;
+
+CREATE UNIQUE INDEX user_email_table_email_uindex
+ON user_email_table(email);
+
+CREATE TABLE IF NOT EXISTS subscription_user_to_tags_table (
+ user_id VARCHAR(32) NOT NULL PRIMARY KEY,
+ tags TEXT NOT NULL
+) WITHOUT ROWID;
+
+CREATE TABLE IF NOT EXISTS subscription_tag_to_users_table (
+ tag VARCHAR(32) NOT NULL PRIMARY KEY,
+ user_ids TEXT NOT NULL
+) WITHOUT ROWID;
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..33eb226
--- /dev/null
+++ b/static/favicon.ico
Binary files differ
diff --git a/static/input.css b/static/input.css
index 6823898..739caa3 100644
--- a/static/input.css
+++ b/static/input.css
@@ -1,67 +1,204 @@
+@import url('https://fonts.googleapis.com/css2?family=Andika:ital,wght@0,400;0,700;1,400;1,700&display=swap');
+
@import "tailwindcss";
@theme {
- --color-olive-dark: #4b513a;
- --color-olive-medium: #8a9d8a;
- --color-olive-light: #a5b8a5;
- --color-cream-dark: #ffffdd;
- --color-cream-light: #fffff0;
- --color-lettuce-dark: #384a0c;
- --color-lettuce-medium: #9bb665;
- --color-lettuce-light: #bbd095;
- --color-sunny-dark: #f5eee2;
- --color-sunny-light: #fff93f;
+ --font-andika: "Andika", sans-serif;
+ --breakpoint-xs: 32rem;
+}
+
+html {
+ font-size: 12px;
+ --crossed-out: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='30' height='30' patternTransform='scale(1) rotate(65)'><rect x='0' y='0' width='100%' height='100%' fill='%232b2b3100' /><path d='M30 15L22.5 30 15 15 22.5 0zm-15 0L7.5 30 0 15 7.5 0z' stroke-width='0.6' stroke='%23ffffffff' fill='none' /></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)' /></svg>");
+ --noise-image: url(data:image/png;base64,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);
+ --split-left-background: unset;
+ --split-left-repeat: no-repeat;
+ --split-left-size: cover;
+ --split-left-position: left;
+ --split-right-background: unset;
+ --split-right-repeat: no-repeat;
+ --split-right-size: cover;
+ --split-right-position: left;
+}
+
+@media screen and (min-width: 640px) and (min-height: 640px) {
+ html {
+ font-size: 14px;
+ }
+}
+
+@media screen and (min-width: 900px) and (min-height: 900px) {
+ html {
+ font-size: 16px;
+ }
+}
+
+@media screen and (min-width: 1400px) and (min-height: 1400px) {
+ html {
+ font-size: 20px;
+ }
+}
- --font-spectral: Spectral, serif;
- --font-patua: "Patua One", cursive;
+@media screen and (min-width: 1920px) and (min-height: 1920px) {
+ html {
+ font-size: 24px;
+ }
}
[data-theme="olive"] {
- --main-dark-color: #4b513a;
+ --main-hard-color: #4b513a;
--main-medium-color: #8a9d8a;
- --main-light-color: #a5b8a5;
+ --main-soft-color: #a5b8a5;
--background-dark-color: #ffffdd;
+ --background-medium-color: var(--background-dark-color);
--background-light-color: #fffff0;
--sidebar-text-color: var(--background-dark-color);
- --sidebar-stroke-color: var(--main-dark-color);
- --secondary-color: var(--main-light-color);
- --interlocked-hexagons-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/olive/interlocked-hexagons.svg');
+ --sidebar-stroke-color: var(--main-hard-color);
+ --secondary-color: var(--main-soft-color);
+ --leaflet-text-color: var(--main-hard-color);
+ --leaflet-link-color: var(--main-medium-color);
+ --leaflet-link-hover-color: var(--main-soft-color);
+ --interlocked-hexagons-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23ffffdb%22%2F%3E%3Cpath%20fill%3D%22%23777e63%22%20d%3D%22m33.64%200-.17.25h-33L4.3%206h33.07l4-6zm8.33%200-4.33%206.5h-33l3.83%205.75h33.06L49.7%200zm8.34%200-8.5%2012.75h-33l3.83%205.75H45.7L58.03%200zm8.33%200L45.97%2019h-33l3.83%205.75h33.06L66.36%200zm-8.5%2025.25L33.63%2050l3.86%205.8L54.03%2031h33l-3.83-5.75zm4.16%206.25L37.8%2056.25l3.87%205.8%2016.53-24.8h33l-3.84-5.75zm4.17%206.25L41.97%2062.5l3.86%205.8%2016.54-24.8h33l-3.84-5.75zM62.63%2044l-16.5%2024.75%203.87%205.8%2016.53-24.8h33L95.7%2044zM100%2050.45l-3.87%205.8%203.87%205.81v-11.6zM0%2050.46v11.6L12.5%2080.8l3.86-5.8zm95.83%206.25-3.87%205.8L100%2074.55v-11.6zm-4.17%206.24-3.86%205.8%2012.2%2018.3v-11.6zM0%2062.96v11.6l8.33%2012.49%203.86-5.8zm87.5%206.24L83.63%2075%20100%2099.54V87.96zM50%2075.45%2033.64%20100h7.72l12.5-18.75zm-50%200v11.6l4.16%206.24%203.87-5.8zm54.17%206.25L41.97%20100h7.73l8.33-12.5zm4.17%206.25L50.3%20100h7.73l4.17-6.25zM0%2087.95v11.59l3.86-5.8zm62.5%206.25-3.87%205.8h7.73z%22%2F%3E%3Cpath%20fill%3D%22%23a0a182%22%20d%3D%22M66.8.25%2062.97%206h33l4.03%206.05V.45l-.14-.2zM0%20.45v11.61L12.5%2030.8l3.86-5.8zM62.63%206.5l-3.83%205.75h33l8.2%2012.3v-11.6L95.7%206.5zm-4.16%206.25-3.84%205.75h33L100%2037.06v-11.6l-8.47-12.71zM0%2012.96v11.6l8.33%2012.49%203.86-5.8zM54.3%2019l-3.84%205.75h33L100%2049.55l.01-.01V37.96L87.36%2019zm-4.77%206.25H41.8L25.3%2050l16.66%2025-16.5%2024.75h7.74L49.7%2075%2033.03%2050zm-8.33%200h-7.74L16.96%2050l16.67%2025-16.5%2024.75h7.73L41.36%2075%2024.7%2050Zm-8.34%200h-7.73L8.63%2050%2025.3%2075%208.8%2099.75h7.73L33.03%2075%2016.36%2050zm-16.07%200L.3%2050l16.67%2025L.46%2099.75H8.2L24.7%2075%208.02%2050l16.5-24.75zM0%2025.46v11.6l4.16%206.24%203.87-5.8zm0%2012.5v11.58l3.86-5.79zm66.8%2012.29L50.3%2074.99l3.87%205.81%204.16%206.25%204.17%206.25%204.3%206.45h7.73L58.03%2075l16.5-24.75zm16.06%200h-7.73L58.63%2075l16.5%2024.75h7.73L66.36%2075zm.6%200L66.96%2075l16.5%2024.75h7.74L74.7%2075l16.5-24.75zm16.07%200H91.8L75.3%2075l16.5%2024.75h7.73L83.03%2075z%22%2F%3E%3C%2Fsvg%3E");
--interlocked-hexagons-background-repeat: repeat;
- --interlocked-hexagons-background-size: 10vw;
- --squares-and-triangles-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/olive/squares-and-triangles.svg');
+ --interlocked-hexagons-background-size: 10dvh;
+ --squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%234a5139%22%2F%3E%3Cpath%20fill%3D%22%23b2b285%22%20d%3D%22M0%200v6.77L6.72%200zm8.91%200L0%208.96v3.86L12.83%200zm6.11%200L0%2015.06v3.87L18.9%200zm6.11%200L0%2021.12v3.92L25%200zM25%200l25%2024.99v-3.87L28.87%200zm25%2024.99%2025%2024.98L100%2025%2075%200zm-50%20.05v3.87l21.13%2021.12H25l-25-25zm25%2024.99h3.87L50%2028.9v-3.87zm0%200L0%2075l25%2025%2025-24.99zM50%2075l25-24.98h-3.87L50%2071.14v3.87zm0%200v3.87L71.13%20100H75L50%2075.01zm25%2025h3.87L100%2078.88v-3.87zm25-24.99v-3.87L78.87%2050.03H75L100%2075zM31.1%200%2050%2018.93v-3.87L34.98%200h-3.87zm6.07%200L50%2012.82V8.96L41.04%200zm6.1%200L50%206.77V0h-6.72zM25%202.19%202.14%2025.04H25zm0%2022.85v22.8l22.81-22.8zM75%203.92l21.13%2021.12L75%2046.16%2053.87%2025.04zm0%202.19L56.06%2025.04l18.89%2018.88%2018.94-18.88L75%206.1zm0%203.92%2015.07%2015L75%2040.06%2059.98%2025.04zm0%202.18L62.17%2025.04%2075%2037.86l12.83-12.82L75%2012.2zm0%203.82%208.96%209L75%2033.95%2066.04%2025zm0%202.24-6.72%206.77L75%2031.76l6.77-6.72h-.05zM0%2031.09v3.87l15.02%2015.07h3.87zm50%200L31.1%2050.03h3.88L50%2034.96V31.1zM0%2037.2v3.87l8.91%208.96h3.92zm50%200L37.17%2050.03h3.87L50%2041.07zM0%2043.3v6.73h6.72zm50%200-6.72%206.73H50zm0%206.73v6.71l6.72-6.71zm8.96%200L50%2058.93v3.92l12.83-12.82zm6.06%200L50%2065.03v3.88l18.94-18.88zm16.09%200L100%2068.9v-3.87l-15.02-15zm6.11%200L100%2062.85v-3.92l-8.91-8.9zm6.06%200%206.72%206.71v-6.71zM75%2052.2%2052.19%2075H75zM75%2075v22.8L97.81%2075zM25%2053.9l21.13%2021.12L25%2096.13%203.87%2075.01zm0%202.2L6.1%2074.95l18.95%2018.88%2018.9-18.88L25%2056.08zm0%203.9%2015.02%2015.01L25%2090.03%209.98%2075zm0%202.19L12.17%2075%2025%2087.84%2037.83%2075%2025%2062.2zm0%203.87L33.96%2075%2025%2083.97%2016.04%2075zm0%202.23-6.72%206.72L25%2081.73l6.72-6.72L25%2068.3zm25%2012.83v3.87L65.02%20100h3.87zm50%200L81.1%20100h3.88L100%2084.99zm-50%206.06v3.86l8.96%208.96h3.87zm50%200L87.22%20100h3.87l8.91-8.96zm-50%206.1V100h6.72zm50%200L93.28%20100H100z%22%2F%3E%3C%2Fsvg%3E");
--squares-and-triangles-background-repeat: repeat;
+ --squares-and-triangles-background-width-coef: 10;
+ --squares-and-triangles-background-height-coef: 10;
}
[data-theme="lettuce"] {
- --main-dark-color: #384a0c;
+ --main-hard-color: #384a0c;
--main-medium-color: #9bb665;
- --main-light-color: #bbd095;
+ --main-soft-color: #bbd095;
--background-dark-color: #f5eee2;
+ --background-medium-color: var(--background-dark-color);
--background-light-color: #fff9ef;
- --sidebar-text-color: var(--main-dark-color);
+ --sidebar-text-color: var(--main-hard-color);
--sidebar-stroke-color: var(--background-dark-color);
--secondary-color: var(--main-medium-color);
- --interlocked-hexagons-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lettuce/interlocked-hexagons.svg');
+ --leaflet-text-color: var(--main-hard-color);
+ --leaflet-link-color: var(--main-medium-color);
+ --leaflet-link-hover-color: var(--main-soft-color);
+ --interlocked-hexagons-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23f5ede1%22%2F%3E%3Cpath%20fill%3D%22%239ab663%22%20d%3D%22m33.64%200-.17.25h-33L4.3%206h33.07l4-6zm8.33%200-4.33%206.5h-33l3.83%205.75h33.06L49.7%200zm8.34%200-8.5%2012.75h-33l3.83%205.75H45.7L58.03%200zm8.33%200L45.97%2019h-33l3.83%205.75h33.06L66.36%200zm-8.5%2025.25L33.63%2050l3.86%205.8L54.03%2031h33l-3.83-5.75zm4.16%206.25L37.8%2056.25l3.87%205.8%2016.53-24.8h33l-3.84-5.75zm4.17%206.25L41.97%2062.5l3.86%205.8%2016.54-24.8h33l-3.84-5.75zM62.63%2044l-16.5%2024.75%203.87%205.8%2016.53-24.8h33L95.7%2044zM100%2050.45l-3.87%205.8%203.87%205.81v-11.6zM0%2050.46v11.6L12.5%2080.8l3.86-5.8zm95.83%206.25-3.87%205.8L100%2074.55v-11.6zm-4.17%206.24-3.86%205.8%2012.2%2018.3v-11.6zM0%2062.96v11.6l8.33%2012.49%203.86-5.8zm87.5%206.24L83.63%2075%20100%2099.54V87.96zM50%2075.45%2033.64%20100h7.72l12.5-18.75zm-50%200v11.6l4.16%206.24%203.87-5.8zm54.17%206.25L41.97%20100h7.73l8.33-12.5zm4.17%206.25L50.3%20100h7.73l4.17-6.25zM0%2087.95v11.59l3.86-5.8zm62.5%206.25-3.87%205.8h7.73z%22%2F%3E%3Cpath%20fill%3D%22%23bbd095%22%20d%3D%22M66.8.25%2062.97%206h33l4.03%206.05V.45l-.14-.2zM0%20.45v11.61L12.5%2030.8l3.86-5.8zM62.63%206.5l-3.83%205.75h33l8.2%2012.3v-11.6L95.7%206.5zm-4.16%206.25-3.84%205.75h33L100%2037.06v-11.6l-8.47-12.71zM0%2012.96v11.6l8.33%2012.49%203.86-5.8zM54.3%2019l-3.84%205.75h33L100%2049.55l.01-.01V37.96L87.36%2019zm-4.77%206.25H41.8L25.3%2050l16.66%2025-16.5%2024.75h7.74L49.7%2075%2033.03%2050zm-8.33%200h-7.74L16.96%2050l16.67%2025-16.5%2024.75h7.73L41.36%2075%2024.7%2050Zm-8.34%200h-7.73L8.63%2050%2025.3%2075%208.8%2099.75h7.73L33.03%2075%2016.36%2050zm-16.07%200L.3%2050l16.67%2025L.46%2099.75H8.2L24.7%2075%208.02%2050l16.5-24.75zM0%2025.46v11.6l4.16%206.24%203.87-5.8zm0%2012.5v11.58l3.86-5.79zm66.8%2012.29L50.3%2074.99l3.87%205.81%204.16%206.25%204.17%206.25%204.3%206.45h7.73L58.03%2075l16.5-24.75zm16.06%200h-7.73L58.63%2075l16.5%2024.75h7.73L66.36%2075zm.6%200L66.96%2075l16.5%2024.75h7.74L74.7%2075l16.5-24.75zm16.07%200H91.8L75.3%2075l16.5%2024.75h7.73L83.03%2075z%22%2F%3E%3C%2Fsvg%3E");
--interlocked-hexagons-background-repeat: repeat;
- --interlocked-hexagons-background-size: 10vw;
- --squares-and-triangles-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lettuce/squares-and-triangles.svg');
+ --interlocked-hexagons-background-size: 10dvh;
+ --squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%22%20height%3D%22100%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%23bbd095%22%2F%3E%3Cpath%20fill%3D%22%23f5ede1%22%20d%3D%22M0%200v6.77L6.72%200zm8.91%200L0%208.96v3.86L12.83%200zm6.11%200L0%2015.06v3.87L18.9%200zm6.11%200L0%2021.12v3.92L25%200zM25%200l25%2024.99v-3.87L28.87%200zm25%2024.99%2025%2024.98L100%2025%2075%200zm-50%20.05v3.87l21.13%2021.12H25l-25-25zm25%2024.99h3.87L50%2028.9v-3.87zm0%200L0%2075l25%2025%2025-24.99zM50%2075l25-24.98h-3.87L50%2071.14v3.87zm0%200v3.87L71.13%20100H75L50%2075.01zm25%2025h3.87L100%2078.88v-3.87zm25-24.99v-3.87L78.87%2050.03H75L100%2075zM31.1%200%2050%2018.93v-3.87L34.98%200h-3.87zm6.07%200L50%2012.82V8.96L41.04%200zm6.1%200L50%206.77V0h-6.72zM25%202.19%202.14%2025.04H25zm0%2022.85v22.8l22.81-22.8zM75%203.92l21.13%2021.12L75%2046.16%2053.87%2025.04zm0%202.19L56.06%2025.04l18.89%2018.88%2018.94-18.88L75%206.1zm0%203.92%2015.07%2015L75%2040.06%2059.98%2025.04zm0%202.18L62.17%2025.04%2075%2037.86l12.83-12.82L75%2012.2zm0%203.82%208.96%209L75%2033.95%2066.04%2025zm0%202.24-6.72%206.77L75%2031.76l6.77-6.72h-.05zM0%2031.09v3.87l15.02%2015.07h3.87zm50%200L31.1%2050.03h3.88L50%2034.96V31.1zM0%2037.2v3.87l8.91%208.96h3.92zm50%200L37.17%2050.03h3.87L50%2041.07zM0%2043.3v6.73h6.72zm50%200-6.72%206.73H50zm0%206.73v6.71l6.72-6.71zm8.96%200L50%2058.93v3.92l12.83-12.82zm6.06%200L50%2065.03v3.88l18.94-18.88zm16.09%200L100%2068.9v-3.87l-15.02-15zm6.11%200L100%2062.85v-3.92l-8.91-8.9zm6.06%200%206.72%206.71v-6.71zM75%2052.2%2052.19%2075H75zM75%2075v22.8L97.81%2075zM25%2053.9l21.13%2021.12L25%2096.13%203.87%2075.01zm0%202.2L6.1%2074.95l18.95%2018.88%2018.9-18.88L25%2056.08zm0%203.9%2015.02%2015.01L25%2090.03%209.98%2075zm0%202.19L12.17%2075%2025%2087.84%2037.83%2075%2025%2062.2zm0%203.87L33.96%2075%2025%2083.97%2016.04%2075zm0%202.23-6.72%206.72L25%2081.73l6.72-6.72L25%2068.3zm25%2012.83v3.87L65.02%20100h3.87zm50%200L81.1%20100h3.88L100%2084.99zm-50%206.06v3.86l8.96%208.96h3.87zm50%200L87.22%20100h3.87l8.91-8.96zm-50%206.1V100h6.72zm50%200L93.28%20100H100z%22%2F%3E%3C%2Fsvg%3E");
--squares-and-triangles-background-repeat: repeat;
+ --squares-and-triangles-background-width-coef: 10;
+ --squares-and-triangles-background-height-coef: 10;
}
[data-theme="night"] {
- --main-dark-color: var(--color-sky-100);
+ --main-hard-color: var(--color-sky-100);
--main-medium-color: var(--color-sky-200);
- --main-light-color: var(--color-sky-300);
+ --main-soft-color: var(--color-sky-300);
--background-dark-color: #13131b;
+ --background-medium-color: var(--background-dark-color);
--background-light-color: #222230;
- --sidebar-text-color: var(--main-dark-color);
+ --sidebar-text-color: var(--main-hard-color);
--sidebar-stroke-color: var(--background-light-color);
- --secondary-color: var(--main-light-color);
+ --secondary-color: var(--main-soft-color);
+ --leaflet-text-color: var(--background-dark-color);
+ --leaflet-link-color: var(--background-dark-color);
+ --leaflet-link-hover-color: var(--background-light-color);
--interlocked-hexagons-background: radial-gradient(33.5% 25% at 100% 82%, rgba(190, 227, 248, 0.2) 0%, rgba(197, 179, 246, 0.13) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(54% 25% at 55% 100%, rgba(190, 227, 248, 0.2) 0%, rgba(197, 179, 246, 0.13) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(37% 19.5% at 20% 100%, rgba(176, 220, 248, 0.3) 0%, rgba(197, 172, 247, 0.2) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(63% 34.5% at 66% 77%, rgba(190, 227, 248, 0.15) 0%, rgba(197, 179, 246, 0.1) 33.33%, rgba(255, 0, 0, 0) 100%), linear-gradient(0deg, #2f5883 0%, #2f4e7a 4.75%, #2e4472 9.5%, #2a3065 19%, #272e57 26.5%, #242b4b 34%, #1f2437 49%, #1c2030 57.75%, #1a1b29 66.5%, #13131b 84%);
--interlocked-hexagons-background-repeat: no-repeat;
- --interlocked-hexagons-background-size: 100vw 100vh;
- --squares-and-triangles-background: 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='60' height='60' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%2313131bff'/><path d='M10 50H0m60-40H50m0 40v10M10 0v10m21 30v20m0-60v20m9 10h20M0 30h20m0 10h20V20H20v10zM10 10h40v40H10zM0 0h60v60H0z' stroke-width='0.5' stroke='%23353543ff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>");
+ --interlocked-hexagons-background-size: 100dvw 100dvh;
+ --squares-and-triangles-background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2260%22%20height%3D%2260%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22%2313131b%22%2F%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23363645%22%20stroke-width%3D%22.5%22%20d%3D%22M10%2050H0m60-40H50m0%2040v10M10%200v10m21%2030v20m0-60v20m9%2010h20M0%2030h20m0%2010h20V20H20v10zM10%2010h40v40H10zM0%200h60v60H0z%22%2F%3E%3C%2Fsvg%3E");
--squares-and-triangles-background-repeat: repeat;
+ --squares-and-triangles-background-width-coef: 5;
+ --squares-and-triangles-background-height-coef: 5;
+}
+
+[data-theme="ram"] {
+ --main-hard-color: #fefad8;
+ --main-medium-color: #f6e4c5;
+ --main-soft-color: #e8d4b8;
+ --background-dark-color: #413b34;
+ --background-medium-color: #62584f;
+ --background-light-color: #6d6258;
+ --sidebar-text-color: var(--main-hard-color);
+ --sidebar-stroke-color: var(--background-light-color);
+ --secondary-color: var(--main-soft-color);
+ --leaflet-text-color: var(--background-dark-color);
+ --leaflet-link-color: var(--background-dark-color);
+ --leaflet-link-hover-color: var(--background-light-color);
+ --interlocked-hexagons-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/ram/ram-background.webp');
+ --interlocked-hexagons-background-repeat: repeat;
+ --interlocked-hexagons-background-size: auto;
+ --squares-and-triangles-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/ram/ram-sidebar.webp');
+ --squares-and-triangles-background-repeat: repeat;
+ --squares-and-triangles-background-width-coef: 5;
+ --squares-and-triangles-background-height-coef: 15;
+}
+
+[data-theme="lilac"] {
+ --main-hard-color: #343b58;
+ --main-medium-color: #414868;
+ --main-soft-color: #6c6e75;
+ --background-dark-color: #c3caff;
+ --background-medium-color: #d1d6ff;
+ --background-light-color: #e3e6ff;
+ --sidebar-text-color: var(--main-hard-color);
+ --sidebar-stroke-color: var(--background-light-color);
+ --secondary-color: var(--main-medium-color);
+ --leaflet-text-color: var(--main-hard-color);
+ --leaflet-link-color: var(--main-medium-color);
+ --leaflet-link-hover-color: var(--main-soft-color);
+ --interlocked-hexagons-background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>");
+ --interlocked-hexagons-background-repeat: repeat;
+ --interlocked-hexagons-background-size: auto;
+ --squares-and-triangles-background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>");
+ --squares-and-triangles-background-repeat: repeat;
+ --squares-and-triangles-background-width-coef: 5;
+ --squares-and-triangles-background-height-coef: 5;
+ --split-left-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lilac/split-left.webp');
+ --split-right-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lilac/split-right.webp');
+}
+
+a, .linklike {
+ color: var(--main-medium-color) !important;
+ text-decoration: underline;
+}
+
+a:hover, .linklike:hover {
+ color: var(--main-soft-color) !important;
+}
+
+#map-container {
+ color: var(--leaflet-text-color) !important;
+}
+
+#map-container a {
+ color: var(--leaflet-link-color) !important;
+ text-decoration: underline;
+}
+
+#map-container a:hover {
+ color: var(--leaflet-link-hover-color) !important;
+ text-decoration: underline;
+}
+
+.crossable.disabled::before,
+.crossable.htmx-request::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--main-hard-color);
+ background-size: 0.5rem 0.5rem;
+ mask-image: var(--crossed-out);
+ opacity: 1;
+ transition: opacity 0.3s;
+ z-index: 25;
+}
+
+form.crossable.disabled input,
+form.crossable.htmx-request input {
+ filter: grayscale(1);
}
.bg-interlocked-hexagons {
@@ -75,6 +212,20 @@
background-repeat: var(--squares-and-triangles-background-repeat);
}
+.bg-split-left {
+ background-image: var(--split-left-background);
+ background-repeat: var(--split-left-repeat);
+ background-size: var(--split-left-size);
+ background-position: var(--split-left-position);
+}
+
+.bg-split-right {
+ background-image: var(--split-right-background);
+ background-repeat: var(--split-right-repeat);
+ background-size: var(--split-right-size);
+ background-position: var(--split-right-position);
+}
+
.text-sidebar {
color: var(--sidebar-text-color);
}
@@ -83,373 +234,499 @@
border-color: var(--sidebar-stroke-color);
}
-.text-main-dark {
- color: var(--main-dark-color);
+.text-main-hard {
+ color: var(--main-hard-color);
}
.text-main-medium {
color: var(--main-medium-color);
}
+.text-main-soft {
+ color: var(--main-soft-color);
+}
+
.text-background-dark {
color: var(--background-dark-color);
}
+.text-background-light {
+ color: var(--background-dark-color);
+}
+
.text-secondary {
color: var(--secondary-color);
}
-.bg-main-dark {
- background-color: var(--main-dark-color);
+.bg-main-hard {
+ background-color: var(--main-hard-color);
+ background-image: url(data:image/png;base64,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);
+}
+
+.bg-main-medium {
+ background-color: var(--main-medium-color);
+ background-image: url(data:image/png;base64,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);
+}
+
+.bg-main-soft {
+ background-color: var(--main-soft-color);
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==);
}
.bg-background-dark {
background-color: var(--background-dark-color);
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==);
+}
+
+.bg-background-medium {
+ background-color: var(--background-medium-color);
+ background-image: var(--noise-image);
}
.bg-background-light {
background-color: var(--background-light-color);
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==);
}
-.border-main-dark {
- border-color: var(--main-dark-color);
+.border-main-hard {
+ border-color: var(--main-hard-color);
}
.border-main-medium {
border-color: var(--main-medium-color);
}
-.desktop-sidebar-custom {
- width: 5vw;
- background-size: 10vw;
+.border-main-soft {
+ border-color: var(--main-soft-color);
}
-.mobile-topbar-custom {
- width: 90vw;
- height: 10vh;
- background-size: 20vh;
- margin-left: 5vw;
+.border-background-dark {
+ border-color: var(--background-dark-color);
}
-.logo-custom {
- width: 3.5vw;
- height: 20vw;
- font-size: 2.5vw;
- -webkit-text-stroke: 0.1vw var(--sidebar-stroke-color);
- writing-mode: sideways-lr;
- text-orientation: mixed;
- display: block;
+.border-background-light {
+ border-color: var(--background-light-color);
}
-.nav-icon-custom {
- font-size: 3vw !important;
- -webkit-text-stroke: 0.1vw var(--sidebar-stroke-color);
- width: 5vw;
- height: 5vw;
- align-content: center;
- text-align: center;
+.border-inset {
+ border-style: inset;
}
-.nav-icon-custom:hover {
- font-size: 4vw;
+.border-outset {
+ border-style: outset;
}
-.content-square {
- width: 100vh;
+.bg-sidebar {
+ background-image: var(--squares-and-triangles-background);
+ background-repeat: var(--squares-and-triangles-background-repeat);
+ background-size: calc(var(--squares-and-triangles-background-width-coef) * 1rem) calc(var(--squares-and-triangles-background-height-coef) * 1rem);
}
-.border-left-custom {
- width: 0.5vw;
- border-right: 0.3vh dashed var(--main-dark-color);
+.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);
}
-.border-right-custom {
- width: 0.5vw;
- border-left: 0.5vh dotted var(--main-dark-color);
+.text-stroke-\(--sidebar-stroke-color\) {
+ -webkit-text-stroke-color: var(--sidebar-stroke-color);
}
-.inset-shadow {
- box-shadow: inset 0vh 1vh 1vh -1vh rgba(75, 81, 58, 0.5),
- inset 1vh 0vh 1vh -1vh rgba(75, 81, 58, 0.5),
- inset -1vh 0vh 1vh -1vh rgba(75, 81, 58, 0.5),
- inset 0vh -0.5vh rgba(75, 81, 58, 0.5);
+.text-stroke-\[0\.1dvw\] {
+ -webkit-text-stroke-width: 0.1dvw;
}
-.desktop {
- display: flex;
+.theme-wheel {
+ position: fixed;
+ width: 12rem;
+ height: 12rem;
+ box-shadow: 0.25rem 0.25rem 0.25rem var(--main-soft-color);
+ border-radius: 50%;
+ opacity: 0;
+ visibility: hidden;
+ transform: scale(0.5);
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+ z-index: 1001;
}
-.mobile {
- display: none;
+.theme-wheel.active {
+ opacity: 1;
+ visibility: visible;
+ transform: scale(1);
}
-.lh-0-9 {
- line-height: 0.9;
+.theme-icon {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ color: white;
+ background: var(--interlocked-hexagons-background);
+ background-size: 100% 100%;
+ font-size: 1.5rem;
}
-.lh-2 {
- line-height: 2;
+.theme-icon:hover {
+ filter: brightness(1.2);
}
-.ls-vmin0-04 {
- letter-spacing: 0.04vmin;
+.theme-icon i {
+ position: absolute;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
+ z-index: 1;
}
-.timl-vmax2 {
- text-indent: 2vmax hanging;
+.theme-icon.active {
+ filter: brightness(1.3);
}
-.text-vmax0-8 {
- font-size: 0.8vmax;
+.theme-icon.active::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(255, 255, 255, 0.25);
+ pointer-events: none;
+ clip-path: inherit;
}
-.text-vmax1 {
- font-size: 1vmax !important;
+.theme-pin {
+ position: relative;
+ background: color-mix(in srgb, var(--main-soft-color) 75%, transparent);
+ box-shadow: 0rem 0rem 1rem var(--main-hard-color);
+ border: 0.5rem solid color-mix(in srgb, var(--main-soft-color) 90%, transparent);
+ backdrop-filter: blur(2px);
+ top: 35%;
+ left: 35%;
+ width: 30%;
+ height: 30%;
+ z-index: 1002;
+ border-radius: 50%;
}
-.text-vmax1-2 {
- font-size: 1.2vmax;
+.custom-svg-marker {
+ background: transparent;
+ border: none;
}
-.text-vmax1-5 {
- font-size: 1.5vmax !important;
+.custom-svg-marker svg {
+ filter: drop-shadow(2px 2px 2px rgba(0,0,0,0.3));
+ transition: transform 0.2s;
}
-.text-vmax2 {
- font-size: 2vmax;
+.custom-svg-marker:hover svg {
+ transform: scale(1.1);
}
-.text-vmax2-5 {
- font-size: 2.5vmax;
+.grid-tooltip {
+ position: relative;
+ display: inline-block;
+ box-shadow: inset 0rem 0rem 0.5rem var(--main-soft-color);
+ transition: box-shadow 0.5s ease-in-out;
}
-.w-vw90 {
- width: 90vw;
+.grid-tooltip:hover {
+ box-shadow: none;
}
-.w-p80 {
- width: 80%;
+.grid-tooltip-text {
+ visibility: hidden;
+ opacity: 0;
+ transition: opacity 0.5s;
+ width: 100%;
+ top: 100%;
+ background-color: var(--background-dark-color);
+ color: var(--main-hard-color);
+ text-align: center;
+ padding: 0.25rem 0.25rem;
+ margin-top: 0.25rem;
+ border-radius: 0.5rem;
+ position: absolute;
+ z-index: 1001;
}
-.w-vmax4 {
- width: 4vmax;
+.grid-tooltip:hover .grid-tooltip-text {
+ visibility: visible;
+ opacity: 1;
}
-.h-vh90 {
- height: 90vh;
+.grid-tooltip-text::after {
+ content: " ";
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ margin-left: -0.25rem;
+ border-width: 0.25rem;
+ border-style: solid;
+ border-color: transparent transparent var(--background-dark-color) transparent;
}
-.h-vmax4 {
- height: 4vmax;
+.grid-item {
+ position: relative;
+ display: inline-block;
+ width: 10rem;
}
-.mt-vmin0-8 {
- margin-top: 0.8vmin;
+.grid-sizer {
+ position: relative;
+ display: inline-block;
+ width: 10.1rem;
}
-.mt-vmin1-6 {
- margin-top: 1.6vmin;
+.grid-item-index {
+ visibility: visible;
+ opacity: 1;
+ transition: opacity 0.5s;
+ top: 0%;
+ left: 0%;
+ background-color: var(--main-soft-color);
+ color: var(--background-dark-color);
+ text-align: center;
+ padding: 0.25rem 0.25rem;
+ position: absolute;
+ z-index: 14;
}
-.mx-vmin1-6 {
- margin-left: 1.6vmin;
- margin-right: 1.6vmin;
+.grid-item:hover .grid-item-index {
+ visibility: hidden;
+ opacity: 0;
}
-.mb-vmin0-4 {
- margin-bottom: 0.4vmin;
+.grid-item-2x {
+ width: 20rem;
}
-.mb-vmin0-6 {
- margin-bottom: 0.8vmin;
-}
+@container main (width >= 40rem) {
+ .grid-item {
+ width: 12rem;
+ }
-.mb-vmin0-8 {
- margin-bottom: 0.8vmin;
-}
+ .grid-sizer {
+ width: 12.1rem;
+ }
-.mb-vmin1-2 {
- margin-bottom: 1.2vmin;
+ .grid-item-2x {
+ width: 24rem;
+ }
}
-.mb-vmin1-6 {
- margin-bottom: 1.6vmin;
-}
+@container main (width >= 76rem) {
+ .grid-item {
+ width: 18rem;
+ }
-.pl-vmin0-8 {
- padding-left: 0.8vmin;
-}
+ .grid-sizer {
+ width: 18.1rem;
+ }
-.pl-vmax2 {
- padding-left: 2vmax;
+ .grid-item-2x {
+ width: 36rem;
+ }
}
-.pr-vmin1-2 {
- padding-right: 1.2vmin;
-}
+@container main (width >= 128rem) {
+ .grid-item {
+ width: 24rem;
+ }
-.p-vmin2-4 {
- padding: 2.4vmin;
-}
+ .grid-sizer {
+ width: 24.1rem;
+ }
-.space-y-vmin0-8 {
- column-gap: 0.8vmin;
+ .grid-item-2x {
+ width: 48rem;
+ }
}
-.lg-backdrop {
- background-color: var(--background-light-color);
+.goverlay {
+ background: color-mix(in srgb, var(--background-light-color) 50%, transparent) !important;
+ backdrop-filter: blur(1rem);
}
-.lg-thumb-outer {
- background-color: var(--background-light-color) !important;
+.glightbox-clean .gslide-description {
+ background: color-mix(in srgb, var(--background-dark-color) 50%, transparent) !important;
+ backdrop-filter: blur(1rem);
}
-.lg-prev {
- background-color: var(--background-light-color);
- color: var(--main-dark-color);
+.glightbox-clean .gslide-title {
+ font-family: var(--font-andika) !important;
+ font-weight: 600 !important;
+ color: var(--main-soft-color) !important;
+ padding-bottom: 1rem !important;
+ margin-bottom: 0 !important;
}
-.lg-next {
- background-color: var(--background-light-color);
- color: var(--main-dark-color);
+.glightbox-clean .gslide-desc {
+ font-size: 1rem !important;
+ font-family: var(--font-andika) !important;
+ font-weight: 400;
+ color: var(--main-medium-color) !important;
}
-.lg-counter {
- color: var(--main-dark-color);
- font-family: 'Spectral';
- font-size: 1vmax;
- padding-top: 0;
- height: 1vmax;
+.glightbox-mobile .glightbox-container .gslide-description {
+ padding: 0 !important;
}
-.lg-toolbar .lg-icon {
- padding: 0 0.7vmax;
- font-size: 1.5vmax !important;
- height: 1.5vmax;
- width: auto;
- color: var(--main-dark-color);
+.glightbox-clean .gdesc-inner {
+ padding: 1rem 1rem !important;
}
-.lg-toolbar .lg-icon:hover {
- color: var(--main-medium-color);
+.glightbox-container .ginner-container {
+ height: 100dvh !important;
}
-.lg-toolbar .lg-icon {
- font-size: 1.5vmax !important;
- color: var(--main-dark-color);
+.gslide-description {
+ flex-grow: 0;
+ flex-shrink: 1;
+ flex-basis: 0;
}
-.lg-content .lg-icon:hover {
- color: var(--main-medium-color);
+.map-container .leaflet-container {
+ width: 100% !important;
+ height: 100% !important;
}
-.lg-sub-html {
- padding: 0.7vmin 1.25vmin !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;
}
-.inline-gallery-container {
- width: 80%;
- height: 60vmin;
-}
+@media (min-aspect-ratio: 0.8/1) {
+ .ar-gt-0\.8\:mr-6 {
+ margin-right: 1.5rem;
+ }
-.light-gallery-captions {
- p {
- transform: translate3d(0, 0, 0px);
- transition-delay: 500ms;
- opacity: 1;
+ .ar-gt-0\.8\:w-fit {
+ width: fit-content;
}
-}
-.lg-slide-progress {
- .light-gallery-captions {
- h4 {
- transform: translate3d(-60px, 0, 0px);
- }
+ .ar-gt-0\.8\:w-\[100dvh\] {
+ width: 100dvh;
+ flex-basis: 100dvh;
+ }
- p {
- transform: translate3d(60px, 0, 0px);
- }
+ .ar-gt-0\.8\:min-w-fit {
+ min-width: fit-content;
+ }
- h4,
- p {
- opacity: 0;
- }
+ .ar-gt-0\.8\:min-w-\[10dvh\] {
+ min-width: 10dvh;
}
-}
-.lg-current {
- &.lg-slide-progress {
- .light-gallery-captions {
+ .ar-gt-0\.8\:max-w-\[20dvh\] {
+ max-width: 20dvh;
+ }
- h4,
- p {
- transition-delay: 0ms;
- }
- }
+ .ar-gt-0\.8\:items-center {
+ align-items: center;
}
}
-/* Mobile responsive styles */
-@media (max-aspect-ratio: 1.1/1) {
- .desktop {
- display: none;
+@media (max-aspect-ratio: 0.8/1) {
+ .ar-lt-0\.8\:bg-background-light {
+ background-color: var(--background-light-color);
+ }
+
+ .bg-pattern-body {
+ background-size: 10dvh;
}
- .mobile {
- display: flex;
+ .ar-lt-0\.8\:grid {
+ display: grid;
}
- .inline-gallery-container {
- width: 100%;
+ .ar-lt-0\.8\:flex-col {
+ flex-direction: column;
}
- .desktop-sidebar-custom {
- width: 90%;
- margin-left: 5vw;
- height: 10vh;
- background-size: 20vh;
+ .ar-lt-0\.8\:flex-row {
+ flex-direction: row;
}
- .logo-custom {
- display: none;
+ .ar-lt-0\.8\:h-\[8dvh\] {
+ height: 8dvh;
}
- .nav-icon-custom {
- font-size: 6vh !important;
- -webkit-text-stroke: 0.1vh var(--sidebar-stroke-color);
- width: 10vh;
- height: 10vh;
- align-content: center;
- text-align: center;
+ .ar-lt-0\.8\:h-\[10dvh\] {
+ height: 10dvh;
}
- .nav-icon-custom:hover {
- font-size: 8vh;
+ .ar-lt-0\.8\:h-\[30dvh\] {
+ height: 30dvh;
}
- .bg-pattern-body {
- background-size: 10vh;
+ .ar-lt-0\.8\:h-\[90dvh\] {
+ height: 90dvh;
+ }
+
+ .ar-lt-0\.8\:w-\[8dvh\] {
+ width: 8dvh;
+ }
+
+ .ar-lt-0\.8\:w-\[80\%\] {
+ width: 80%;
+ }
+
+ .ar-lt-0\.8\:w-\[90dvw\] {
+ width: 90dvw;
+ }
+
+ .ar-lt-0\.8\:w-\[100dvw\] {
+ width: 100dvw;
+ }
+
+ .ar-lt-0\.8\:max-h-\[30\%\] {
+ max-height: 30%;
+ }
+
+ .ar-lt-0\.8\:max-h-\[80\%\] {
+ max-height: 80%;
}
- .border-left-custom {
- width: 1%;
- border-right: 0.3vw dashed var(--main-dark-color);
+ .ar-lt-0\.8\:max-h-\[92dvh\] {
+ max-height: 92dvh;
}
- .border-right-custom {
- width: 1.5%;
- border-left: 0.5vw dotted var(--main-dark-color);
+ .ar-lt-0\.8\:text-\[5dvh\] {
+ font-size: 5dvh;
+ }
+
+ .ar-lt-0\.8\:hidden {
+ display: none;
+ }
+}
+
+@media (min-aspect-ratio: 0.6/1) {
+ .ar-gt-0\.6\:flex-3\/5 {
+ flex: 60%;
+ }
+
+ .ar-gt-0\.6\:flex-2\/5 {
+ flex: 40%;
}
}
-@media (max-aspect-ratio: 0.4/1) {
- .desktop-sidebar-custom {
- width: 100%;
- margin-left: 0;
+@media (max-aspect-ratio: 0.6/1) {
+ .ar-lt-0\.6\:flex-col {
+ flex-direction: column;
}
}
-.star {
+.home-page-heart {
+ position: absolute;
+ color: var(--color-pink-700);
+ user-select: none;
+ pointer-events: none;
+ font-family: serif;
+}
+
+.night-star {
color: var(--color-yellow-100);
animation-name: blink;
animation-duration: 5s;
@@ -458,6 +735,13 @@
-webkit-user-select: none;
}
+.ram-frame {
+ width: 20vmin;
+ height: 20vmin;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
@keyframes blink {
0% {
opacity: 1;
diff --git a/static/output.css b/static/output.css
deleted file mode 100644
index 2e6d1bd..0000000
--- a/static/output.css
+++ /dev/null
@@ -1,908 +0,0 @@
-/*! tailwindcss v4.1.11 | MIT License | https://tailwindcss.com */
-@layer properties;
-@layer theme, base, components, utilities;
-@layer theme {
- :root, :host {
- --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
- 'Noto Color Emoji';
- --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
- monospace;
- --color-yellow-100: oklch(97.3% 0.071 103.193);
- --color-sky-100: oklch(95.1% 0.026 236.824);
- --color-sky-200: oklch(90.1% 0.058 230.902);
- --color-sky-300: oklch(82.8% 0.111 230.318);
- --spacing: 0.25rem;
- --font-weight-medium: 500;
- --font-weight-bold: 700;
- --font-weight-extrabold: 800;
- --radius-md: 0.375rem;
- --radius-lg: 0.5rem;
- --radius-4xl: 2rem;
- --default-transition-duration: 150ms;
- --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- --default-font-family: var(--font-sans);
- --default-mono-font-family: var(--font-mono);
- --font-spectral: Spectral, serif;
- --font-patua: "Patua One", cursive;
- }
-}
-@layer base {
- *, ::after, ::before, ::backdrop, ::file-selector-button {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- border: 0 solid;
- }
- html, :host {
- line-height: 1.5;
- -webkit-text-size-adjust: 100%;
- tab-size: 4;
- font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
- font-feature-settings: var(--default-font-feature-settings, normal);
- font-variation-settings: var(--default-font-variation-settings, normal);
- -webkit-tap-highlight-color: transparent;
- }
- hr {
- height: 0;
- color: inherit;
- border-top-width: 1px;
- }
- abbr:where([title]) {
- -webkit-text-decoration: underline dotted;
- text-decoration: underline dotted;
- }
- h1, h2, h3, h4, h5, h6 {
- font-size: inherit;
- font-weight: inherit;
- }
- a {
- color: inherit;
- -webkit-text-decoration: inherit;
- text-decoration: inherit;
- }
- b, strong {
- font-weight: bolder;
- }
- code, kbd, samp, pre {
- font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
- font-feature-settings: var(--default-mono-font-feature-settings, normal);
- font-variation-settings: var(--default-mono-font-variation-settings, normal);
- font-size: 1em;
- }
- small {
- font-size: 80%;
- }
- sub, sup {
- font-size: 75%;
- line-height: 0;
- position: relative;
- vertical-align: baseline;
- }
- sub {
- bottom: -0.25em;
- }
- sup {
- top: -0.5em;
- }
- table {
- text-indent: 0;
- border-color: inherit;
- border-collapse: collapse;
- }
- :-moz-focusring {
- outline: auto;
- }
- progress {
- vertical-align: baseline;
- }
- summary {
- display: list-item;
- }
- ol, ul, menu {
- list-style: none;
- }
- img, svg, video, canvas, audio, iframe, embed, object {
- display: block;
- vertical-align: middle;
- }
- img, video {
- max-width: 100%;
- height: auto;
- }
- button, input, select, optgroup, textarea, ::file-selector-button {
- font: inherit;
- font-feature-settings: inherit;
- font-variation-settings: inherit;
- letter-spacing: inherit;
- color: inherit;
- border-radius: 0;
- background-color: transparent;
- opacity: 1;
- }
- :where(select:is([multiple], [size])) optgroup {
- font-weight: bolder;
- }
- :where(select:is([multiple], [size])) optgroup option {
- padding-inline-start: 20px;
- }
- ::file-selector-button {
- margin-inline-end: 4px;
- }
- ::placeholder {
- opacity: 1;
- }
- @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
- ::placeholder {
- color: currentcolor;
- @supports (color: color-mix(in lab, red, red)) {
- color: color-mix(in oklab, currentcolor 50%, transparent);
- }
- }
- }
- textarea {
- resize: vertical;
- }
- ::-webkit-search-decoration {
- -webkit-appearance: none;
- }
- ::-webkit-date-and-time-value {
- min-height: 1lh;
- text-align: inherit;
- }
- ::-webkit-datetime-edit {
- display: inline-flex;
- }
- ::-webkit-datetime-edit-fields-wrapper {
- padding: 0;
- }
- ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
- padding-block: 0;
- }
- :-moz-ui-invalid {
- box-shadow: none;
- }
- button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
- appearance: button;
- }
- ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
- height: auto;
- }
- [hidden]:where(:not([hidden='until-found'])) {
- display: none !important;
- }
-}
-@layer utilities {
- .absolute {
- position: absolute;
- }
- .fixed {
- position: fixed;
- }
- .relative {
- position: relative;
- }
- .z-0 {
- z-index: 0;
- }
- .z-1 {
- z-index: 1;
- }
- .z-10 {
- z-index: 10;
- }
- .z-20 {
- z-index: 20;
- }
- .container {
- width: 100%;
- @media (width >= 40rem) {
- max-width: 40rem;
- }
- @media (width >= 48rem) {
- max-width: 48rem;
- }
- @media (width >= 64rem) {
- max-width: 64rem;
- }
- @media (width >= 80rem) {
- max-width: 80rem;
- }
- @media (width >= 96rem) {
- max-width: 96rem;
- }
- }
- .m-2 {
- margin: calc(var(--spacing) * 2);
- }
- .mx-auto {
- margin-inline: auto;
- }
- .my-auto {
- margin-block: auto;
- }
- .mt-auto {
- margin-top: auto;
- }
- .mr-2 {
- margin-right: calc(var(--spacing) * 2);
- }
- .mr-auto {
- margin-right: auto;
- }
- .mb-auto {
- margin-bottom: auto;
- }
- .ml-auto {
- margin-left: auto;
- }
- .block {
- display: block;
- }
- .flex {
- display: flex;
- }
- .h-auto {
- height: auto;
- }
- .h-screen {
- height: 100vh;
- }
- .max-w-full {
- max-width: 100%;
- }
- .flex-1 {
- flex: 1;
- }
- .flex-3 {
- flex: 3;
- }
- .flex-5 {
- flex: 5;
- }
- .flex-grow {
- flex-grow: 1;
- }
- .grow {
- flex-grow: 1;
- }
- .transform {
- transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
- }
- .cursor-pointer {
- cursor: pointer;
- }
- .resize {
- resize: both;
- }
- .list-inside {
- list-style-position: inside;
- }
- .list-decimal {
- list-style-type: decimal;
- }
- .list-disc {
- list-style-type: disc;
- }
- .flex-col {
- flex-direction: column;
- }
- .flex-row {
- flex-direction: row;
- }
- .items-center {
- align-items: center;
- }
- .justify-center {
- justify-content: center;
- }
- .justify-items-center {
- justify-items: center;
- }
- .gap-0 {
- gap: calc(var(--spacing) * 0);
- }
- .gap-4 {
- gap: calc(var(--spacing) * 4);
- }
- .justify-self-center {
- justify-self: center;
- }
- .overflow-hidden {
- overflow: hidden;
- }
- .overflow-x-auto {
- overflow-x: auto;
- }
- .overflow-y-auto {
- overflow-y: auto;
- }
- .rounded-4xl {
- border-radius: var(--radius-4xl);
- }
- .rounded-lg {
- border-radius: var(--radius-lg);
- }
- .rounded-md {
- border-radius: var(--radius-md);
- }
- .border-t-4 {
- border-top-style: var(--tw-border-style);
- border-top-width: 4px;
- }
- .border-r-1 {
- border-right-style: var(--tw-border-style);
- border-right-width: 1px;
- }
- .border-dashed {
- --tw-border-style: dashed;
- border-style: dashed;
- }
- .border-dotted {
- --tw-border-style: dotted;
- border-style: dotted;
- }
- .bg-repeat {
- background-repeat: repeat;
- }
- .object-cover {
- object-fit: cover;
- }
- .p-4 {
- padding: calc(var(--spacing) * 4);
- }
- .pr-2 {
- padding-right: calc(var(--spacing) * 2);
- }
- .text-center {
- text-align: center;
- }
- .text-left {
- text-align: left;
- }
- .text-right {
- text-align: right;
- }
- .font-patua {
- font-family: var(--font-patua);
- }
- .font-spectral {
- font-family: var(--font-spectral);
- }
- .font-bold {
- --tw-font-weight: var(--font-weight-bold);
- font-weight: var(--font-weight-bold);
- }
- .font-extrabold {
- --tw-font-weight: var(--font-weight-extrabold);
- font-weight: var(--font-weight-extrabold);
- }
- .font-medium {
- --tw-font-weight: var(--font-weight-medium);
- font-weight: var(--font-weight-medium);
- }
- .italic {
- font-style: italic;
- }
- .underline {
- text-decoration-line: underline;
- }
- .opacity-70 {
- opacity: 70%;
- }
- .opacity-100 {
- opacity: 100%;
- }
- .shadow-2xl {
- --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
- box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
- }
- .shadow-lg {
- --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
- box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
- }
- .transition-colors {
- transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
- transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
- transition-duration: var(--tw-duration, var(--default-transition-duration));
- }
- .duration-300 {
- --tw-duration: 300ms;
- transition-duration: 300ms;
- }
- .select-none {
- -webkit-user-select: none;
- user-select: none;
- }
-}
-[data-theme="olive"] {
- --main-dark-color: #4b513a;
- --main-medium-color: #8a9d8a;
- --main-light-color: #a5b8a5;
- --background-dark-color: #ffffdd;
- --background-light-color: #fffff0;
- --sidebar-text-color: var(--background-dark-color);
- --sidebar-stroke-color: var(--main-dark-color);
- --secondary-color: var(--main-light-color);
- --interlocked-hexagons-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/olive/interlocked-hexagons.svg');
- --interlocked-hexagons-background-repeat: repeat;
- --interlocked-hexagons-background-size: 10vw;
- --squares-and-triangles-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/olive/squares-and-triangles.svg');
- --squares-and-triangles-background-repeat: repeat;
-}
-[data-theme="lettuce"] {
- --main-dark-color: #384a0c;
- --main-medium-color: #9bb665;
- --main-light-color: #bbd095;
- --background-dark-color: #f5eee2;
- --background-light-color: #fff9ef;
- --sidebar-text-color: var(--main-dark-color);
- --sidebar-stroke-color: var(--background-dark-color);
- --secondary-color: var(--main-medium-color);
- --interlocked-hexagons-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lettuce/interlocked-hexagons.svg');
- --interlocked-hexagons-background-repeat: repeat;
- --interlocked-hexagons-background-size: 10vw;
- --squares-and-triangles-background: url('https://f003.backblazeb2.com/file/sayana-static/themes/lettuce/squares-and-triangles.svg');
- --squares-and-triangles-background-repeat: repeat;
-}
-[data-theme="night"] {
- --main-dark-color: var(--color-sky-100);
- --main-medium-color: var(--color-sky-200);
- --main-light-color: var(--color-sky-300);
- --background-dark-color: #13131b;
- --background-light-color: #222230;
- --sidebar-text-color: var(--main-dark-color);
- --sidebar-stroke-color: var(--background-light-color);
- --secondary-color: var(--main-light-color);
- --interlocked-hexagons-background: radial-gradient(33.5% 25% at 100% 82%, rgba(190, 227, 248, 0.2) 0%, rgba(197, 179, 246, 0.13) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(54% 25% at 55% 100%, rgba(190, 227, 248, 0.2) 0%, rgba(197, 179, 246, 0.13) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(37% 19.5% at 20% 100%, rgba(176, 220, 248, 0.3) 0%, rgba(197, 172, 247, 0.2) 33.33%, rgba(255, 0, 0, 0) 100%), radial-gradient(63% 34.5% at 66% 77%, rgba(190, 227, 248, 0.15) 0%, rgba(197, 179, 246, 0.1) 33.33%, rgba(255, 0, 0, 0) 100%), linear-gradient(0deg, #2f5883 0%, #2f4e7a 4.75%, #2e4472 9.5%, #2a3065 19%, #272e57 26.5%, #242b4b 34%, #1f2437 49%, #1c2030 57.75%, #1a1b29 66.5%, #13131b 84%);
- --interlocked-hexagons-background-repeat: no-repeat;
- --interlocked-hexagons-background-size: 100vw 100vh;
- --squares-and-triangles-background: 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='60' height='60' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%2313131bff'/><path d='M10 50H0m60-40H50m0 40v10M10 0v10m21 30v20m0-60v20m9 10h20M0 30h20m0 10h20V20H20v10zM10 10h40v40H10zM0 0h60v60H0z' stroke-width='0.5' stroke='%23353543ff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>");
- --squares-and-triangles-background-repeat: repeat;
-}
-.bg-interlocked-hexagons {
- background-image: var(--interlocked-hexagons-background);
- background-repeat: var(--interlocked-hexagons-background-repeat);
- background-size: var(--interlocked-hexagons-background-size);
-}
-.bg-squares-and-triangles {
- background-image: var(--squares-and-triangles-background);
- background-repeat: var(--squares-and-triangles-background-repeat);
-}
-.text-sidebar {
- color: var(--sidebar-text-color);
-}
-.stroke-sidebar {
- border-color: var(--sidebar-stroke-color);
-}
-.text-main-dark {
- color: var(--main-dark-color);
-}
-.text-main-medium {
- color: var(--main-medium-color);
-}
-.text-background-dark {
- color: var(--background-dark-color);
-}
-.text-secondary {
- color: var(--secondary-color);
-}
-.bg-main-dark {
- background-color: var(--main-dark-color);
-}
-.bg-background-dark {
- background-color: var(--background-dark-color);
-}
-.bg-background-light {
- background-color: var(--background-light-color);
-}
-.border-main-dark {
- border-color: var(--main-dark-color);
-}
-.border-main-medium {
- border-color: var(--main-medium-color);
-}
-.desktop-sidebar-custom {
- width: 5vw;
- background-size: 10vw;
-}
-.mobile-topbar-custom {
- width: 90vw;
- height: 10vh;
- background-size: 20vh;
- margin-left: 5vw;
-}
-.logo-custom {
- width: 3.5vw;
- height: 20vw;
- font-size: 2.5vw;
- -webkit-text-stroke: 0.1vw var(--sidebar-stroke-color);
- writing-mode: sideways-lr;
- text-orientation: mixed;
- display: block;
-}
-.nav-icon-custom {
- font-size: 3vw !important;
- -webkit-text-stroke: 0.1vw var(--sidebar-stroke-color);
- width: 5vw;
- height: 5vw;
- align-content: center;
- text-align: center;
-}
-.nav-icon-custom:hover {
- font-size: 4vw;
-}
-.content-square {
- width: 100vh;
-}
-.border-left-custom {
- width: 0.5vw;
- border-right: 0.3vh dashed var(--main-dark-color);
-}
-.border-right-custom {
- width: 0.5vw;
- border-left: 0.5vh dotted var(--main-dark-color);
-}
-.inset-shadow {
- box-shadow: inset 0vh 1vh 1vh -1vh rgba(75, 81, 58, 0.5), inset 1vh 0vh 1vh -1vh rgba(75, 81, 58, 0.5), inset -1vh 0vh 1vh -1vh rgba(75, 81, 58, 0.5), inset 0vh -0.5vh rgba(75, 81, 58, 0.5);
-}
-.desktop {
- display: flex;
-}
-.mobile {
- display: none;
-}
-.lh-0-9 {
- line-height: 0.9;
-}
-.lh-2 {
- line-height: 2;
-}
-.ls-vmin0-04 {
- letter-spacing: 0.04vmin;
-}
-.timl-vmax2 {
- text-indent: 2vmax hanging;
-}
-.text-vmax0-8 {
- font-size: 0.8vmax;
-}
-.text-vmax1 {
- font-size: 1vmax !important;
-}
-.text-vmax1-2 {
- font-size: 1.2vmax;
-}
-.text-vmax1-5 {
- font-size: 1.5vmax !important;
-}
-.text-vmax2 {
- font-size: 2vmax;
-}
-.text-vmax2-5 {
- font-size: 2.5vmax;
-}
-.w-vw90 {
- width: 90vw;
-}
-.w-p80 {
- width: 80%;
-}
-.w-vmax4 {
- width: 4vmax;
-}
-.h-vh90 {
- height: 90vh;
-}
-.h-vmax4 {
- height: 4vmax;
-}
-.mt-vmin0-8 {
- margin-top: 0.8vmin;
-}
-.mt-vmin1-6 {
- margin-top: 1.6vmin;
-}
-.mx-vmin1-6 {
- margin-left: 1.6vmin;
- margin-right: 1.6vmin;
-}
-.mb-vmin0-4 {
- margin-bottom: 0.4vmin;
-}
-.mb-vmin0-6 {
- margin-bottom: 0.8vmin;
-}
-.mb-vmin0-8 {
- margin-bottom: 0.8vmin;
-}
-.mb-vmin1-2 {
- margin-bottom: 1.2vmin;
-}
-.mb-vmin1-6 {
- margin-bottom: 1.6vmin;
-}
-.pl-vmin0-8 {
- padding-left: 0.8vmin;
-}
-.pl-vmax2 {
- padding-left: 2vmax;
-}
-.pr-vmin1-2 {
- padding-right: 1.2vmin;
-}
-.p-vmin2-4 {
- padding: 2.4vmin;
-}
-.space-y-vmin0-8 {
- column-gap: 0.8vmin;
-}
-.lg-backdrop {
- background-color: var(--background-light-color);
-}
-.lg-thumb-outer {
- background-color: var(--background-light-color) !important;
-}
-.lg-prev {
- background-color: var(--background-light-color);
- color: var(--main-dark-color);
-}
-.lg-next {
- background-color: var(--background-light-color);
- color: var(--main-dark-color);
-}
-.lg-counter {
- color: var(--main-dark-color);
- font-family: 'Spectral';
- font-size: 1vmax;
- padding-top: 0;
- height: 1vmax;
-}
-.lg-toolbar .lg-icon {
- padding: 0 0.7vmax;
- font-size: 1.5vmax !important;
- height: 1.5vmax;
- width: auto;
- color: var(--main-dark-color);
-}
-.lg-toolbar .lg-icon:hover {
- color: var(--main-medium-color);
-}
-.lg-toolbar .lg-icon {
- font-size: 1.5vmax !important;
- color: var(--main-dark-color);
-}
-.lg-content .lg-icon:hover {
- color: var(--main-medium-color);
-}
-.lg-sub-html {
- padding: 0.7vmin 1.25vmin !important;
-}
-.inline-gallery-container {
- width: 80%;
- height: 60vmin;
-}
-.light-gallery-captions {
- p {
- transform: translate3d(0, 0, 0px);
- transition-delay: 500ms;
- opacity: 1;
- }
-}
-.lg-slide-progress {
- .light-gallery-captions {
- h4 {
- transform: translate3d(-60px, 0, 0px);
- }
- p {
- transform: translate3d(60px, 0, 0px);
- }
- h4, p {
- opacity: 0;
- }
- }
-}
-.lg-current {
- &.lg-slide-progress {
- .light-gallery-captions {
- h4, p {
- transition-delay: 0ms;
- }
- }
- }
-}
-@media (max-aspect-ratio: 1.1/1) {
- .desktop {
- display: none;
- }
- .mobile {
- display: flex;
- }
- .inline-gallery-container {
- width: 100%;
- }
- .desktop-sidebar-custom {
- width: 90%;
- margin-left: 5vw;
- height: 10vh;
- background-size: 20vh;
- }
- .logo-custom {
- display: none;
- }
- .nav-icon-custom {
- font-size: 6vh !important;
- -webkit-text-stroke: 0.1vh var(--sidebar-stroke-color);
- width: 10vh;
- height: 10vh;
- align-content: center;
- text-align: center;
- }
- .nav-icon-custom:hover {
- font-size: 8vh;
- }
- .bg-pattern-body {
- background-size: 10vh;
- }
- .border-left-custom {
- width: 1%;
- border-right: 0.3vw dashed var(--main-dark-color);
- }
- .border-right-custom {
- width: 1.5%;
- border-left: 0.5vw dotted var(--main-dark-color);
- }
-}
-@media (max-aspect-ratio: 0.4/1) {
- .desktop-sidebar-custom {
- width: 100%;
- margin-left: 0;
- }
-}
-.star {
- color: var(--color-yellow-100);
- animation-name: blink;
- animation-duration: 5s;
- animation-iteration-count: infinite;
- user-select: none;
- -webkit-user-select: none;
-}
-@keyframes blink {
- 0% {
- opacity: 1;
- }
- 20% {
- opacity: 0.2;
- }
- 80% {
- opacity: 0.2;
- }
- 100% {
- opacity: 1;
- }
-}
-@property --tw-rotate-x {
- syntax: "*";
- inherits: false;
-}
-@property --tw-rotate-y {
- syntax: "*";
- inherits: false;
-}
-@property --tw-rotate-z {
- syntax: "*";
- inherits: false;
-}
-@property --tw-skew-x {
- syntax: "*";
- inherits: false;
-}
-@property --tw-skew-y {
- syntax: "*";
- inherits: false;
-}
-@property --tw-border-style {
- syntax: "*";
- inherits: false;
- initial-value: solid;
-}
-@property --tw-font-weight {
- syntax: "*";
- inherits: false;
-}
-@property --tw-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-shadow-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-shadow-alpha {
- syntax: "<percentage>";
- inherits: false;
- initial-value: 100%;
-}
-@property --tw-inset-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-inset-shadow-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-inset-shadow-alpha {
- syntax: "<percentage>";
- inherits: false;
- initial-value: 100%;
-}
-@property --tw-ring-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-ring-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-inset-ring-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-inset-ring-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-ring-inset {
- syntax: "*";
- inherits: false;
-}
-@property --tw-ring-offset-width {
- syntax: "<length>";
- inherits: false;
- initial-value: 0px;
-}
-@property --tw-ring-offset-color {
- syntax: "*";
- inherits: false;
- initial-value: #fff;
-}
-@property --tw-ring-offset-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-duration {
- syntax: "*";
- inherits: false;
-}
-@layer properties {
- @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
- *, ::before, ::after, ::backdrop {
- --tw-rotate-x: initial;
- --tw-rotate-y: initial;
- --tw-rotate-z: initial;
- --tw-skew-x: initial;
- --tw-skew-y: initial;
- --tw-border-style: solid;
- --tw-font-weight: initial;
- --tw-shadow: 0 0 #0000;
- --tw-shadow-color: initial;
- --tw-shadow-alpha: 100%;
- --tw-inset-shadow: 0 0 #0000;
- --tw-inset-shadow-color: initial;
- --tw-inset-shadow-alpha: 100%;
- --tw-ring-color: initial;
- --tw-ring-shadow: 0 0 #0000;
- --tw-inset-ring-color: initial;
- --tw-inset-ring-shadow: 0 0 #0000;
- --tw-ring-inset: initial;
- --tw-ring-offset-width: 0px;
- --tw-ring-offset-color: #fff;
- --tw-ring-offset-shadow: 0 0 #0000;
- --tw-duration: initial;
- }
- }
-}
diff --git a/views/index.html b/views/index.html
deleted file mode 100644
index 6d4bc2f..0000000
--- a/views/index.html
+++ /dev/null
@@ -1,142 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>SAYA TODAY // Заглавная</title>
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet">
- <link href="/output.css" rel="stylesheet">
- <style>
- .menu-outer-body {
- border-radius: 10%;
- width: 60vw;
- height: 40vh;
- }
-
- .menu-inner-body {
- width: 85%;
- }
-
- @media (max-aspect-ratio: 1.2/1) {
- .menu-inner-body {
- width: 92.5%;
- }
- }
-
- @media (max-aspect-ratio: 0.8/1) {
- .menu-outer-body {
- width: 100vw;
- height: 80vh;
- }
-
- .menu-inner-body {
- width: 100%;
- }
- }
- </style>
-</head>
-
-<body data-theme="night" class="font-spectral text-main-dark text-vmax1-5 overflow-hidden bg-interlocked-hexagons h-screen flex flex-col">
-
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
- <div class="absolute star z-0">*</div>
-
- <script>
- function switchTheme(theme) {
- if (theme == "" || typeof theme == "undefined") {
- const currentTheme = document.body.getAttribute('data-theme');
- switch (currentTheme) {
- case "olive": theme = "lettuce"; break;
- case "lettuce": theme = "night"; break;
- case "night": theme = "olive"; break;
- }
- }
- // localStorage.setItem('theme', theme);
- document.body.setAttribute('data-theme', theme);
- }
-
- const savedTheme = 'night';
- switchTheme(savedTheme);
- </script>
-
- <div class="menu-outer-body p-4 z-1 bg-background-dark flex flex-col mx-auto my-auto opacity-70">
- <p class="font-patua mx-auto text-vmax2-5 mt-vmin0-8 mb-vmin1-6">saya.today</p>
- <div class="menu-inner-body grow bg-background-light flex flex-col mx-auto mb-vmin1-6 overflow-y-auto opacity-100">
- {{- range .BlogPages }}
- <div class="m-2 flex flex-row justify-center justify-items-center gap-4">
- <img style="flex-grow: 0;" class="flex-1 object-cover rounded-4xl select-none w-vmax4 h-vmax4" src="https://f003.backblazeb2.com/file/sayana-photos/thumbnails/{{ .Thumbnail }}.webp">
- <div class="flex-5 justify-self-center flex-col border-r-1 border-dashed border-opacity-50 border-main-medium">
- <a href="/blog/{{ .Link }}" class="block font-extrabold">{{ .Title }}</a>
- <a href="/blog/{{ .Link }}" class="block italic text-vmax1 text-secondary">{{ .ActionDate }}</a>
- <p>{{ .ShortDescription }}</p>
- </div>
- <div class="flex-3 justify-self-center flex flex-col text-right">
- <p class="grow text-vmax1">
- Тэги: {{ .Tags }}
- </p>
- <p class="italic text-secondary text-vmax1">
- {{ .PublishedTime }}
- </p>
- </div>
- </div>
- {{- end }}
- </div>
- </div>
-
- <script>
- function generateStars() {
- var clWidth = document.documentElement.clientWidth;
- var clHeight = document.documentElement.clientHeight;
-
- stars = document.getElementsByClassName("star");
- for (let starIndex = 0; starIndex < stars.length; starIndex++) {
- const star = stars[starIndex];
- const x = Math.floor(Math.random() * clWidth);
- const y = Math.floor(Math.random() * clHeight * 0.8);
- const rot = Math.random() * 90;
- const animDuration = (Math.random() * 10) + 3;
- const sz = Math.floor(Math.random() * 8) + 12;
- star.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`;
- star.style.animationDuration = `${animDuration}s`;
- star.style.fontSize = `${sz}px`;
- }
- }
-
- document.addEventListener('DOMContentLoaded', generateStars);
-
- let generateStarsTimer;
- window.addEventListener("resize", () => {
- clearTimeout(generateStarsTimer);
- generateStarsTimer = setTimeout(() => {
- generateStars();
- }, 1000);
- });
- </script>
-</body>
-</html>
diff --git a/views/layouts/blog-page.html b/views/layouts/blog-page.html
deleted file mode 100644
index 6ee7d85..0000000
--- a/views/layouts/blog-page.html
+++ /dev/null
@@ -1,128 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>SAYA TODAY // {{ .Title }}</title>
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lightgallery.min.css">
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lg-zoom.min.css">
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lg-thumbnail.min.css">
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet">
- <link href="/output.css" rel="stylesheet">
-</head>
-
-<body data-theme="olive" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons bg-repeat bg-pattern-body h-screen flex">
-
- <script>
- function switchTheme(theme) {
- if (theme == "" || typeof theme == "undefined") {
- const currentTheme = document.body.getAttribute('data-theme');
- switch (currentTheme) {
- case "olive": theme = "lettuce"; break;
- case "lettuce": theme = "night"; break;
- case "night": theme = "olive"; break;
- }
- }
- localStorage.setItem('theme', theme);
- document.body.setAttribute('data-theme', theme);
- }
-
- const savedTheme = localStorage.getItem('theme') || 'olive';
- switchTheme(savedTheme);
- </script>
-
- <div class="desktop-sidebar-custom fixed bg-squares-and-triangles bg-repeat flex-col items-center shadow-2xl z-20 h-screen desktop">
- <div class="logo-custom text-sidebar font-patua font-extrabold text-center relative z-20">
- saya.today
- </div>
- <div class="flex flex-col gap-0 relative z-20">
- <i onclick="location.href='/';" class="fas fa-home nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- <i class="fas fa-search nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- <i class="fas fa-images nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- <i onclick="switchTheme('')" class="fas fa-swatchbook nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- </div>
- </div>
-
- <div class="bg-background-dark z-10 h-screen desktop" style="padding-left: 5vw;">
- <div class="border-left-custom bg-background-dark"></div>
-
- <div class="content-square flex flex-col bg-background-dark relative mt-vmin1-6 mx-vmin1-6">
- <div class="flex flex-row mb-vmin0-4">
- <i onclick="location.href='/';" class="fas fa-circle-left hover:bg-main-dark hover:bg-opacity-10 cursor-pointer transition-colors duration-300 rounded-md text-vmax2 mt-auto mb-auto pl-vmin0-8 pr-2"></i>
- <p class="text-vmax2 font-spectral italic font-extrabold select-none mt-auto pl-vmin0-8 pr-vmin1-2">//</p>
- <p class="text-vmax2 font-spectral italic text-left mt-auto">{{ .Title }}</p>
- <p class="grow text-vmax1-5 font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p>
- </div>
- <hr class="border-t-4 border-dotted border-main-dark mb-vmin0-8">
-
- <div class="bg-background-light flex flex-col flex-1 overflow-y-auto inset-shadow p-vmin2-4">
- {{ .ParsedMarkdownDesktop }}
- </div>
-
- <div class="flex flex-row mt-vmin0-8 mb-vmin0-4">
- <p class="grow text-vmax0-8 lh-0-9 font-spectral italic text-right text-secondary mr-2">
- <a href="">{{ .Title }}</a> © {{ .PublishedYear }} by <a href="https://t.me/EarlInisMona">Saya Andy</a> is licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>
- </p>
- <img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;">
- <img src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;">
- <img src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;">
- </div>
-
- <hr class="border-t-4 border-dotted border-main-dark mb-vmin0-8">
- </div>
-
- <div class="border-right-custom bg-background-dark shadow-2xl"></div>
- </div>
-
- <div class="mobile-topbar-custom fixed bg-squares-and-triangles bg-repeat flex-row items-center shadow-2xl w-vw90 z-20 mobile">
- <div class="flex flex-row gap-0 relative z-20">
- <i onclick="location.href='/';" class="fas fa-home nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- <i class="fas fa-search nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- <i class="fas fa-images nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- <i onclick="switchTheme('')" class="fas fa-swatchbook nav-icon-custom text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex justify-center items-center rounded-lg"></i>
- </div>
- <div class="logo-custom text-background-dark font-patua font-extrabold text-center relative grow z-20">
- demo.saya.today
- </div>
- </div>
-
- <div class="bg-background-dark flex-row z-10 w-vw90 h-vh90 mobile" style="margin-left: 5vw; margin-top: 10vh;">
- <div class="border-left-custom bg-background-dark"></div>
-
- <div class="content-square flex flex-col bg-background-dark relative mt-vmin1-6 mx-vmin1-6">
- <div class="flex flex-row mb-vmin0-4">
- <i onclick="location.href='/';" class="fas fa-circle-left hover:bg-main-dark hover:bg-opacity-10 cursor-pointer transition-colors duration-300 rounded-md text-vmax2 mt-auto mb-auto pl-vmin0-8 pr-2"></i>
- <p class="text-vmax2 font-spectral italic font-extrabold select-none mt-auto pl-vmin0-8 pr-vmin1-2">//</p>
- <p class="text-vmax2 font-spectral italic text-left mt-auto">{{ .Title }}</p>
- <p class="grow text-vmax1-5 font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p>
- </div>
- <hr class="border-t-4 border-dotted border-main-dark mb-vmin0-8">
-
- <div class="bg-background-light flex flex-col flex-1 overflow-y-auto inset-shadow p-vmin2-4">
- {{ .ParsedMarkdownMobile }}
- </div>
-
- <div class="flex flex-row mt-vmin0-8 mb-vmin0-4">
- <p class="grow text-vmax0-8 lh-0-9 font-spectral italic text-right text-secondary mr-2">
- <a href="">{{ .Title }}</a> © {{ .PublishedYear }} by <a href="https://t.me/EarlInisMona">Saya Andy</a> is licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>
- </p>
- <img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;">
- <img src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;">
- <img src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;">
- </div>
-
- <hr class="border-t-4 border-dotted border-main-dark mb-vmin0-8">
- </div>
-
- <div class="border-right-custom bg-background-dark shadow-2xl"></div>
- </div>
-
- <script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/lightgallery.min.js"></script>
- <script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/plugins/lg-zoom.min.js"></script>
- <script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/plugins/lg-thumbnail.min.js"></script>
-
-</body>
-</html>
diff --git a/views/layouts/general-mail.html b/views/layouts/general-mail.html
new file mode 100644
index 0000000..8f42c7a
--- /dev/null
+++ b/views/layouts/general-mail.html
@@ -0,0 +1,105 @@
+<!DOCTYPE html>
+<html lang="en">
+ <meta charset="UTF-8">
+ <head>
+ <style>
+@font-face {
+ font-family: 'Philosopher';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(https://fonts.gstatic.com/s/philosopher/v21/vEFV2_5QCwIS4_Dhez5jcWBuT0020Nqf.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: 'Philosopher';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(https://fonts.gstatic.com/s/philosopher/v21/vEFV2_5QCwIS4_Dhez5jcWBqT0020NqfZ7c.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+html {
+ font-family: 'Philosopher', sans-serif;
+ --noise-image: url(data:image/png;base64,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);
+}
+
+body {
+ background-color: #e3e6ff;
+}
+
+a {
+ text-decoration: underline;
+ text-shadow: none !important;
+}
+
+.header {
+ width: 100%;
+ padding: 8px 8px 8px 32px;
+ font-size: 48px;
+ font-family: 'Philosopher';
+ color: white;
+ text-shadow: -2px 0 #343b58, 0 2px #343b58, 2px 0 #343b58, 0 -2px #343b58;
+ background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>");
+ background-color: #e3e6ff;
+ background-size: contain;
+ background-repeat: repeat;
+}
+
+.content {
+ margin: 0 32px 0 32px;
+ padding: 16px 16px 32px 16px;
+ font-size: 24px;
+ text-shadow: -1px 0 #343b58, 0 1px #343b58, 1px 0 #343b58, 0 -1px #343b58;
+ color: white;
+ background-color: #c3caff;
+ background-image: var(--noise-image);
+}
+
+.footer {
+ margin: 0;
+ padding: 1px 8px 1px 8px;
+ font-size: 16px;
+ color: white;
+}
+
+.darkened {
+ background-color: #9aa5f4;
+ background-image: var(--noise-image);
+}
+
+.outlier {
+ padding-inline: 4px;
+ background-color: #6c6e75;
+ background-image: var(--noise-image);
+}
+
+table, th, td {
+ border: 1px solid black;
+}
+
+td {
+ padding: 5px;
+}
+
+img {
+ width: 200px;
+}
+ </style>
+ </head>
+
+ <body>
+ <div id="header" class="header">
+ saya.today
+ </div>
+
+ <div id="content" class="content" style="background-color: #e3e6ff !important;">
+ {{ block "body" . }}{{ end }}
+ </div>
+
+ <div id="footer" class="footer darkened">
+ {{ block "footer" . }}{{ end }}
+ </div>
+ </body>
+</html>
diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html
new file mode 100644
index 0000000..a7281e8
--- /dev/null
+++ b/views/layouts/general-page.html
@@ -0,0 +1,410 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/glightbox/3.3.0/css/glightbox.min.css">
+ <link href="/output.css" rel="stylesheet">
+</head>
+
+<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh flex flex-row">
+
+ <script>
+ let map = null;
+ </script>
+
+ <script>
+ function changeUrl(path, toAppend = false) {
+ const urlParams = new URLSearchParams(window.location.search);
+ const newPathname = location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname;
+ const newPath = toAppend ? `${newPathname}${path}` : `${path}`;
+
+ history.pushState({}, '', `${newPath}${urlParams.entries.length == 0 ? '' : '?' + urlParams.toString()}`);
+ window.dispatchEvent(new Event('popstate'));
+ return false;
+ };
+
+ function convertRemToPixels(rem) {
+ return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
+ }
+ </script>
+
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/glightbox/3.3.0/js/glightbox.min.js"></script>
+
+ <script>
+ function sfc32(a, b, c, d) {
+ return function() {
+ a |= 0; b |= 0; c |= 0; d |= 0;
+ let t = (a + b | 0) + d | 0;
+ d = d + 1 | 0;
+ a = b ^ b >>> 9;
+ b = c + (c << 3) | 0;
+ c = (c << 21 | c >>> 11);
+ c = c + t | 0;
+ return (t >>> 0) / 4294967296;
+ }
+ }
+
+ function cyrb128(str) {
+ let h1 = 1779033703, h2 = 3144134277,
+ h3 = 1013904242, h4 = 2773480762;
+ for (let i = 0, k; i < str.length; i++) {
+ k = str.charCodeAt(i);
+ h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
+ h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
+ h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
+ h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
+ }
+ h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
+ h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
+ h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
+ h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
+ h1 ^= (h2 ^ h3 ^ h4), h2 ^= h1, h3 ^= h1, h4 ^= h1;
+ return [h1>>>0, h2>>>0, h3>>>0, h4>>>0];
+ }
+
+ var seed = cyrb128("{{ .Title }}");
+ var rand = sfc32(seed[0], seed[1], seed[2], seed[3]);
+
+ function calculateVmin(percent) {
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ const smallerDimension = Math.min(viewportWidth, viewportHeight);
+
+ const vminValue = (smallerDimension / 100) * percent;
+
+ return vminValue;
+ }
+
+ function switchThemeDecor(theme) {
+ document.querySelectorAll('.theme-decor').forEach((e) => {
+ e.remove();
+ });
+
+ if (theme == "" || typeof theme == "undefined") {
+ theme = document.body.getAttribute('data-theme');
+ }
+
+ vmin = calculateVmin(1);
+ var clWidth = document.documentElement.clientWidth;
+ var clHeight = document.documentElement.clientHeight;
+
+ switch (theme) {
+ case "olive": break;
+ case "lettuce": break;
+ case "night":
+ for (i = 0; i < 25; i++) {
+ let star = document.createElement("div");
+ star.append("*");
+ star.classList.add("night-star", "theme-decor", "z-0", "absolute");
+ const x = Math.floor(rand() * clWidth);
+ const y = Math.floor(rand() * clHeight * 0.8);
+ const rot = rand() * 90;
+ const animDuration = (rand() * 10) + 3;
+ const sz = Math.floor(rand() * 8) + 12;
+ star.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`;
+ star.style.animationDuration = `${animDuration}s`;
+ star.style.fontSize = `${sz}px`;
+ document.body.append(star);
+ }
+ break;
+ case "ram":
+ const frameCenters = new Array(4);
+ for (i = 1; i <= 4; i++) {
+ let frame = document.createElement("img");
+ frame.src = `https://f003.backblazeb2.com/file/sayana-static/themes/ram/ram-frame${i}.webp`;
+ frame.classList.add("ram-frame", "theme-decor", "z-0", "absolute");
+ let x, y;
+ outerLoop:
+ while (true) {
+ x = Math.floor(rand() * clWidth - 20*vmin);
+ y = Math.floor(rand() * clHeight - 20*vmin);
+ for (j = 0; j < i-1; j++) {
+ const xd = frameCenters[j].x - x;
+ const yd = frameCenters[j].y - y;
+ if (Math.sqrt(xd*xd + yd*yd) < 30*vmin) {
+ continue outerLoop;
+ }
+ }
+ break;
+ }
+ frameCenters[i-1] = {x: x, y: y};
+ const rot = rand() * 90 - 45;
+ frame.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`;
+ document.body.append(frame);
+ }
+ break;
+ }
+ }
+
+ function switchTheme(theme) {
+ if (theme == "" || typeof theme == "undefined") {
+ const currentTheme = document.body.getAttribute('data-theme');
+ switch (currentTheme) {
+ case "olive": theme = "lettuce"; break;
+ case "lettuce": theme = "night"; break;
+ case "night": theme = "ram"; break;
+ case "ram": theme = "lilac"; break;
+ case "lilac": theme = "olive"; break;
+ }
+ }
+ localStorage.setItem('theme', theme);
+ document.body.setAttribute('data-theme', theme);
+ switchThemeDecor(theme);
+ }
+
+ const savedTheme = localStorage.getItem('theme') || 'lilac';
+ switchTheme(savedTheme);
+ </script>
+
+ <div id="general-page-top-embeds" class="flex"
+ hx-get="/api/v1/general-page/top-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-top-embeds-trigger" hx-get="/api/v1/general-page/top-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-top-embeds" hx-swap="innerHTML"></div>
+
+ <script>
+ const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ </script>
+
+ <div class="grow bg-split-left"></div>
+
+ <div class="flex flex-col [@media(max-height:32rem)]:flex-row">
+ <div id="sidebar" class="bg-sidebar flex sticky flex-col z-20 w-dvw xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] [@media(max-height:32rem)]:w-[5rem] h-[5rem] [@media(max-height:32rem)]:h-dvh shadow-[0_0_1rem_black]">
+ <div class="text-[3rem] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1dvw] flex flex-row [@media(max-height:32rem)]:flex-col gap-0 relative z-20">
+ <a href="./" id="sidebar-back-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/../', true);" class="fas fa-circle-left flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <a href="/{{ .Lang }}" id="sidebar-home-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/{{ .Lang }}');" class="fas fa-home flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <a href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/{{ .Lang }}/blog');" class="fas fa-search flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="w-[5rem] h-[5rem] hidden">
+ <i class="fas fa-map flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <i onclick="toggleThemeWheel();" id="sidebar-theme-button" class="fas fa-swatchbook flex w-[5rem] h-[5rem] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 rounded-lg"></i>
+ <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ </div>
+ <div class="theme-wheel" id="theme-wheel">
+ <div class="theme-icon" data-theme="olive"></div>
+ <div class="theme-icon" data-theme="lettuce"></div>
+ <div class="theme-icon" data-theme="night"></div>
+ <div class="theme-icon" data-theme="ram"></div>
+ <div class="theme-icon" data-theme="lilac"></div>
+ <div class="theme-pin"></div>
+ </div>
+ <script>
+ sidebarHomeButton = document.getElementById("sidebar-home-button");
+ sidebarMapButton = document.getElementById("sidebar-map-button");
+ sidebarBlogButton = document.getElementById("sidebar-blog-button");
+ sidebarBackButton = document.getElementById("sidebar-back-button");
+ sidebarUserButton = document.getElementById("sidebar-user-button");
+
+ function updateSidebar() {
+ if (location.pathname == "" || location.pathname == "/") {
+ sidebarHomeButton.classList.add('hidden');
+ sidebarMapButton.classList.add('hidden');
+ sidebarBlogButton.classList.add('hidden');
+ sidebarBackButton.classList.add('hidden');
+ sidebarUserButton.classList.add('hidden');
+ } else {
+ sidebarHomeButton.classList.remove('hidden');
+ sidebarMapButton.classList.remove('hidden');
+ sidebarBlogButton.classList.remove('hidden');
+ sidebarBackButton.classList.remove('hidden');
+ sidebarUserButton.classList.remove('hidden');
+ lang = location.pathname.split("/")[1];
+ sidebarHomeButton.setAttribute('href', `/${lang}`);
+ sidebarHomeButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}', false);`);
+ sidebarMapButton.setAttribute('href', `/${lang}/map`);
+ sidebarBlogButton.setAttribute('href', `/${lang}/blog`);
+ sidebarBlogButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}/blog', false);`);
+ sidebarUserButton.setAttribute('href', `/${lang}/user`);
+ sidebarUserButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}/user', false);`);
+ }
+ }
+ window.addEventListener("popstate", updateSidebar);
+ window.addEventListener("load", updateSidebar, {once: true});
+ </script>
+ </div>
+
+ <div class="bg-background-dark @container/main flex z-10 w-dvw xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] grow-0 h-[calc(100dvh-5rem)] [@media(max-height:32rem)]:h-dvh shadow-[0_0_1rem_black]">
+ <div class="bg-background-dark border-r-2 border-dashed border-main-hard"></div>
+
+ <div class="max-h-100% flex flex-col grow bg-background-dark relative pt-4 ml-8 pr-4">
+ <div id="general-page-header" class="max-xs:hidden [@media(max-height:32rem)]:hidden flex flex-col basis-8"
+ hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-header-trigger" hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-header" hx-swap="innerHTML"></div>
+
+ <hr class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-4 border-dotted border-main-hard mb-2">
+
+ <div id="general-page-body" class="flex flex-col grow overflow-y-auto"
+ hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-body-trigger" hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-body" hx-swap="innerHTML"></div>
+
+ <div id="general-page-footer" class="flex flex-row basis-4 my-1"
+ hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-footer-trigger" hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-footer" hx-swap="innerHTML"></div>
+
+ <hr class="border-t-4 border-dotted border-main-hard mb-2">
+ </div>
+
+ <div class="pr-[0.5dvw] bg-background-dark shadow-2xl border-l-2 border-dotted border-main-hard"></div>
+ </div>
+ </div>
+
+ <div class="grow bg-split-right"></div>
+
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/htmx/2.0.6/htmx.min.js"></script>
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script>
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/imagesloaded/5.0.0/imagesloaded.pkgd.min.js"></script>
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/masonry-layout/4.2.2/masonry.pkgd.min.js"></script>
+
+ <div id="general-page-bottom-embeds" class="flex"
+ hx-get="/api/v1/general-page/bottom-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-bottom-embeds-trigger" hx-get="/api/v1/general-page/bottom-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-bottom-embeds" hx-swap="innerHTML"></div>
+
+ <script>
+ const themeWheel = document.getElementById('theme-wheel');
+ const themeButton = document.getElementById('sidebar-theme-button');
+ const themeIcons = document.querySelectorAll('.theme-icon');
+ const themeWheelRadius = convertRemToPixels(6);
+
+ themeIcons.forEach(icon => {
+ if (icon.getAttribute('data-theme') == savedTheme) {
+ icon.classList.add('active');
+ }
+ });
+
+ 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;
+
+ themeIcons.forEach((icon, index) => {
+ const startAngle = index * sliceAngle;
+ const endAngle = (index + 1) * sliceAngle;
+
+ const clipPath = createSliceClipPath(startAngle, endAngle, themeWheelRadius);
+ icon.style.clipPath = clipPath;
+ });
+ }
+
+ function createSliceClipPath(startAngle, endAngle, radius) {
+ const centerX = radius;
+ const centerY = radius;
+
+ let points = `${centerX}px ${centerY}px`;
+
+ const segments = Math.max(2, Math.ceil(Math.abs(endAngle - startAngle) / 5));
+ const angleStep = (endAngle - startAngle) / segments;
+
+ for (let i = 0; i <= segments; i++) {
+ const angle = ((startAngle + angleStep * i - 90) * Math.PI) / 180;
+ const x = centerX + Math.cos(angle) * radius;
+ const y = centerY + Math.sin(angle) * radius;
+ points += `, ${x}px ${y}px`;
+ }
+
+ points += `, ${centerX}px ${centerY}px`;
+
+ return `polygon(${points})`;
+ }
+
+ function positionThemeWheel() {
+ const buttonRect = themeButton.getBoundingClientRect();
+ const buttonCenterX = buttonRect.left + buttonRect.width / 2;
+ const buttonCenterY = buttonRect.top + buttonRect.height / 2;
+
+ const margin = 0;
+
+ let wheelX = buttonCenterX - themeWheelRadius;
+ let wheelY = buttonCenterY - themeWheelRadius;
+
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ if (wheelX < margin) {
+ wheelX = margin;
+ } else if (wheelX + (themeWheelRadius * 2) > viewportWidth - margin) {
+ wheelX = viewportWidth - (themeWheelRadius * 2) - margin;
+ }
+
+ if (wheelY < margin) {
+ wheelY = margin;
+ } else if (wheelY + (themeWheelRadius * 2) > viewportHeight - margin) {
+ wheelY = viewportHeight - (themeWheelRadius * 2) - margin;
+ }
+
+ themeWheel.style.left = wheelX + 'px';
+ themeWheel.style.top = wheelY + 'px';
+ }
+
+ function hideThemeWheel() {
+ themeWheel.classList.remove('active');
+ }
+
+ function showThemeWheel() {
+ positionThemeWheel();
+ positionThemeIcons();
+ themeWheel.classList.add('active');
+ }
+
+ function toggleThemeWheel() {
+ if (themeWheel.classList.contains('active')) {
+ hideThemeWheel();
+ } else {
+ showThemeWheel();
+ }
+ }
+
+ themeIcons.forEach(icon => {
+ icon.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const theme = icon.getAttribute('data-theme');
+ switchTheme(theme);
+ themeIcons.forEach(icon => {
+ icon.classList.remove('active');
+ })
+ icon.classList.add('active');
+ });
+ });
+
+ themeWheel.addEventListener('mouseleave', hideThemeWheel);
+
+ document.addEventListener('click', (e) => {
+ if (!themeWheel.contains(e.target) && e.target !== themeButton) {
+ hideThemeWheel();
+ }
+ });
+ </script>
+
+ <script>
+ let decorResizeTimeout;
+ window.addEventListener('resize', function() {
+ clearTimeout(decorResizeTimeout);
+ decorResizeTimeout = setTimeout(switchThemeDecor, 300);
+ });
+ </script>
+
+</body>
+</html>
diff --git a/views/messages/new-post.html b/views/messages/new-post.html
new file mode 100644
index 0000000..f1338f7
--- /dev/null
+++ b/views/messages/new-post.html
@@ -0,0 +1,19 @@
+{{ define "body" }}
+<p>{{ .L.Mail.NewPost.Intro }}</p>
+<table>
+ <tr>
+ <td rowspan="3"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}"><img src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Post.Metadata.Thumbnail }}.webp"></a></td>
+ <td class="darkened" style="font-size: 24px"><a style="color: #273de1 !important;" href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}">{{ .Post.Metadata.Title }}</a></td>
+ </tr>
+ <tr>
+ <td class="darkened" style="font-size: 16px">{{ .Post.Metadata.ShortDescription }}</td>
+ </tr>
+ <tr>
+ <td class="darkened" style="font-size: 16px">{{ .L.Mail.NewPost.CapturedOn }} {{ .Post.Metadata.ActionDate }}</td>
+ </tr>
+</table>
+{{ end }}
+
+{{ define "footer" }}
+<p>{{ .UnsubscribeFooter }}</p>
+{{ end }}
diff --git a/views/messages/verify-email.html b/views/messages/verify-email.html
new file mode 100644
index 0000000..dd6ef33
--- /dev/null
+++ b/views/messages/verify-email.html
@@ -0,0 +1,10 @@
+{{ define "body" }}
+<p>{{ .L.Mail.VerifyEmail.Welcome }}</p>
+<p>{{ .L.Mail.VerifyEmail.Intro }}</p>
+<ol>
+ <li>{{ .L.Mail.VerifyEmail.GotoLink }} <a href="https://{{ .ClientHost }}/{{ .Lang }}/user?email_code={{ .VerificationCode }}" class="outlier">&gt;&gt;&gt;</a></li>
+ <li>{{ .L.Mail.VerifyEmail.InputCode }} <span class="outlier">{{ .VerificationCode }}</span><span></li>
+</ol>
+<p>{{ .L.Mail.VerifyEmail.IfRandom }}</p>
+<a href="https://{{ .ClientHost }}" class="outlier">{{ .ClientHost }}</a>
+{{ end }} \ No newline at end of file
diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html
new file mode 100644
index 0000000..d209205
--- /dev/null
+++ b/views/pages/blog-catalogue.html
@@ -0,0 +1,164 @@
+{{ define "body" }}
+<div class="flex flex-row ar-lt-0.8:flex-col text-base h-full">
+ <form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML"
+ class="tags-list ar-gt-0.8:min-w-[10dvh] ar-gt-0.8:max-w-[20dvh] ar-gt-0.8:w-fit ar-lt-0.8:w-full ar-lt-0.8:max-h-[30%] flex shrink-0 flex-col ar-gt-0.8:mr-6 bg-background-light inset-shadow-[0_0_0.4rem_black]">
+ <div class="flex flex-col overflow-y-auto">
+ <fieldset>
+ <legend class="font-bold w-full text-center">{{ .L.BlogSearch.TagsHeader }}</legend>
+ <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 xs:grid-cols-4 sm:grid-cols-5 grid-flow-row-dense">
+ <div class="m-1">
+ <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label>
+ </div>
+ {{- range .Tags }}
+ <div class="m-1">
+ <label><input type="checkbox" id="tag{{ .Name }}Checkbox" name="tags[]" value="{{ .Name }}" onclick="contextAll();" {{ if contains $.QueryTags .Name }}checked{{ end }}> {{ .Name }} <span class="text-secondary italic">{{ .Count }}</span></label>
+ </div>
+ {{- end }}
+ </div>
+ </fieldset>
+ <fieldset class="mt-1">
+ <legend class="font-bold w-full text-center">{{ .L.BlogSearch.OrderByHeader }}</legend>
+ <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col">
+ <div class="m-1">
+ <label><input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-a-z"></i></label>
+ </div>
+ <div class="m-1">
+ <label><input type="radio" id="sortTitleDesc" name="sort" value="titleDesc" {{ if eq .QuerySort "titleDesc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-z-a"></i></label>
+ </div>
+ <div class="m-1">
+ <label><input type="radio" id="sortActionDateAsc" name="sort" value="actionDateAsc" {{ if eq .QuerySort "actionDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label>
+ </div>
+ <div class="m-1">
+ <label><input type="radio" id="sortActionDateDesc" name="sort" value="actionDateDesc" {{ if eq .QuerySort "actionDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label>
+ </div>
+ <div class="m-1">
+ <label><input type="radio" id="sortPublicationDateAsc" name="sort" value="publicationDateAsc" {{ if eq .QuerySort "publicationDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label>
+ </div>
+ <div class="m-1">
+ <label><input type="radio" id="sortPublicationDateDesc" name="sort" value="publicationDateDesc" {{ if eq .QuerySort "publicationDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ <hr class="border-t-2 border-dotted border-main-hard my-0.5">
+ <div class="flex flex-row bg-background-dark my-2 p-0.5 rounded-1 justify-items-center">
+ <i onclick="defaultSearchParams(); cleanHrefParams();" class="fas fa-rotate-left flex-1 text-center text-2xl hover:bg-background-light cursor-pointer transition-colors duration-300"></i>
+ <div class="w-1 bg-background-light grow-0"></div>
+ <button class="flex-1" type="submit">
+ <i onclick="setHrefParams();" class="fas fa-magnifying-glass text-center text-2xl hover:bg-background-light cursor-pointer transition-colors duration-300"></i>
+ </button>
+ </div>
+ </form>
+
+ <div class="w-[0.5dvh] border-r-2 border-dotted border-main-hard bg-background-dark"></div>
+
+ <div hx-get="/api/v1/blog-search" hx-vals='js:{lang: "{{ .Lang }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load" hx-include=".tags-list"
+ class="blog-cards grow flex flex-col overflow-y-auto bg-background-light inset-shadow-[0_0_0.4rem_black] p-6">
+ Loading...
+ </div>
+</div>
+
+<script>
+ function selectAll() {
+ const tagsContainers = document.querySelectorAll('form.tags-list');
+ tagsContainers.forEach(tagsContainer => {
+ const selectAllCheckbox = tagsContainer.querySelector('#tagsAllCheckbox');
+ const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]:not([id="tagsAllCheckbox"])');
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = selectAllCheckbox.checked;
+ });
+ });
+ }
+
+ function contextAll() {
+ const tagsContainers = document.querySelectorAll('form.tags-list');
+ tagsContainers.forEach(tagsContainer => {
+ const selectAllCheckbox = tagsContainer.querySelector('#tagsAllCheckbox');
+ const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]:not([id="tagsAllCheckbox"])');
+ var areAllChecked = true;
+ checkboxes.forEach(checkbox => {
+ if (!checkbox.checked) areAllChecked = false;
+ });
+ selectAllCheckbox.checked = areAllChecked;
+ });
+ }
+
+ function defaultSearchParams() {
+ const tagsContainers = document.querySelectorAll('form.tags-list');
+ tagsContainers.forEach(tagsContainer => {
+ const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]');
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = true;
+ });
+ const sortRadio = tagsContainer.querySelector('#sortPublicationDateDesc');
+ sortRadio.checked = true;
+ });
+ }
+
+ function cleanHrefParams() {
+ const url = new URL(window.location.href);
+ url.search = '';
+
+ window.history.pushState({}, '', url.toString());
+ }
+
+ function setHrefParams() {
+ const url = new URL(window.location.href);
+ url.search = '';
+
+ const tagsContainers = document.querySelectorAll('form.tags-list');
+ tagsContainers.forEach(tagsContainer => {
+ const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]');
+ checkboxes.forEach(checkbox => {
+ if (checkbox.checked)
+ url.searchParams.append('tags[]', checkbox.value);
+ });
+ const sortRadio = tagsContainer.querySelector('input[type="radio"]:checked');
+ url.searchParams.set('sort', sortRadio.value);
+ });
+
+ window.history.pushState({}, '', url.toString());
+ }
+
+ function pickedTagSetHrefParams(tag) {
+ const url = new URL(window.location.href);
+ url.searchParams.delete('tags[]');
+ url.searchParams.set('tags[]', tag);
+
+ const tagsContainers = document.querySelectorAll('form.tags-list');
+ tagsContainers.forEach(tagsContainer => {
+ const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]');
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = false;
+ });
+ chosenCheckbox = tagsContainer.querySelector(`#tag${tag}Checkbox`);
+ chosenCheckbox.checked = true;
+ });
+
+ window.history.pushState({}, '', url.toString());
+ }
+
+ document.addEventListener('htmx:afterRequest', (e) => {
+ if (e.detail.xhr.status == 404 || e.detail.successful != true) {
+ return console.error(e);
+ }
+ if (e.detail.target.id == 'general-page-body') {
+ contextAll();
+
+ const tagsContainers = document.querySelectorAll('form.tags-list');
+ tagsContainers.forEach(tagsContainer => {
+ const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]');
+ anyChecked = false;
+ checkboxes.forEach(checkbox => {
+ anyChecked = checkbox.checked ? true : anyChecked;
+ });
+ if (!anyChecked) {
+ const selectAllCheckbox = tagsContainer.querySelector('#tagsAllCheckbox');
+ selectAllCheckbox.checked = true;
+ selectAll();
+ }
+ });
+ }
+ }, {once: true});
+</script>
+{{ end }}
diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html
new file mode 100644
index 0000000..7170ad8
--- /dev/null
+++ b/views/pages/blog-page.html
@@ -0,0 +1,61 @@
+{{ define "body" }}
+<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4 overflow-y-auto">
+ <p class="max-xs:block [@media(max-height:32rem)]:block hidden font-andika text-2xl font-bold italic text-main-hard tracking-[.0125rem] mb-1">{{ .Title }}</p>
+ <p class="max-xs:block [@media(max-height:32rem)]:block hidden grow text-lg font-andika text-secondary">{{ .L.Metadata.Published }}: {{ .PublishedDate }}</p>
+ <p class="max-xs:block [@media(max-height:32rem)]:block hidden grow text-lg font-andika text-secondary">{{ .L.Metadata.Action }}: {{ .ActionDate }}</p>
+ <hr class="max-xs:block [@media(max-height:32rem)]:block hidden border-t-2 border-dotted border-main-hard mt-1 mb-2 w-full ml-auto mr-auto">
+ {{ .ParsedMarkdown }}
+{{- if .MapLocationX }}
+ <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto">
+ <div id="map-container" class="relative p-1 flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40dvh] ar-lt-0.8:h-[30dvh]"></div>
+ <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%] ml-auto mr-auto">
+
+ <script>
+ function initLocationMap(id, x, y, relativeErrorMeters = 0) {
+ map = L.map(id).setView([x, y], 8);
+
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+ }).addTo(map);
+
+ if (relativeErrorMeters != 0) {
+ var circle = L.circle([x, y], {
+ color: '#8a9d8a',
+ fillColor: '#8a9d8a',
+ fillOpacity: 0.15,
+ radius: relativeErrorMeters
+ }).addTo(map);
+ }
+
+ 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
+ });
+ </script>
+{{- end }}
+</div>
+{{ end }}
+
+{{ define "footer" }}
+<div hx-get="/api/v1/like" hx-target="this" hx-swap="outerHTML" hx-trigger="load"></div>
+{{ end }}
diff --git a/views/pages/global-map.html b/views/pages/global-map.html
new file mode 100644
index 0000000..306f442
--- /dev/null
+++ b/views/pages/global-map.html
@@ -0,0 +1,298 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{ .L.GlobalMap.Header }} // SAYA TODAY</title>
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
+ {{ block "header" . }}{{ end }}
+ <link href="/output.css" rel="stylesheet">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.css">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css">
+</head>
+
+<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh">
+
+ <script>
+ function switchTheme(theme) {
+ if (theme == "" || typeof theme == "undefined") {
+ const currentTheme = document.body.getAttribute('data-theme');
+ switch (currentTheme) {
+ case "olive": theme = "lettuce"; break;
+ case "lettuce": theme = "night"; break;
+ case "night": theme = "ram"; break;
+ case "ram": theme = "lilac"; break;
+ case "lilac": theme = "olive"; break;
+ }
+ }
+ localStorage.setItem('theme', theme);
+ document.body.setAttribute('data-theme', theme);
+ }
+
+ const savedTheme = localStorage.getItem('theme') || 'lilac';
+ switchTheme(savedTheme);
+ </script>
+
+ <script>
+ const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ function convertRemToPixels(rem) {
+ return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
+ }
+ </script>
+
+ <div id="sidebar" class="bg-sidebar block fixed shadow-[0_0_0.4rem_black] z-1001 w-[5rem] h-auto">
+ <div class="text-[3rem] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1dvw] flex flex-col gap-0 relative z-1001">
+ <a href="./" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-circle-left flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <a href="/{{ .Lang }}" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-home flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <a href="/{{ .Lang }}/blog" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-search flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <a href="/{{ .Lang }}/map" class="w-[5rem] h-[5rem]">
+ <i class="fas fa-map flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook flex w-[5rem] h-[5rem] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 rounded-lg"></i>
+ <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="w-[5rem] h-[5rem]">
+ <i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
+ </a>
+ </div>
+ <div class="theme-wheel" id="theme-wheel">
+ <div class="theme-icon" data-theme="olive"></div>
+ <div class="theme-icon" data-theme="lettuce"></div>
+ <div class="theme-icon" data-theme="night"></div>
+ <div class="theme-icon" data-theme="ram"></div>
+ <div class="theme-icon" data-theme="lilac"></div>
+ <div class="theme-pin"></div>
+ </div>
+ </div>
+
+ <div id="map-container" class="bg-background-dark flex z-10 w-full h-full"></div>
+
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/htmx/2.0.6/htmx.min.js"></script>
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script>
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/leaflet.markercluster.js"></script>
+
+ <script>
+ const themeWheel = document.getElementById('theme-wheel');
+ const themeButton = document.getElementById('theme-button');
+ const themeIcons = document.querySelectorAll('.theme-icon');
+ const themeWheelRadius = convertRemToPixels(6);
+
+ themeIcons.forEach(icon => {
+ if (icon.getAttribute('data-theme') == savedTheme) {
+ icon.classList.add('active');
+ }
+ });
+
+ 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;
+
+ themeIcons.forEach((icon, index) => {
+ const startAngle = index * sliceAngle;
+ const endAngle = (index + 1) * sliceAngle;
+
+ const clipPath = createSliceClipPath(startAngle, endAngle, themeWheelRadius);
+ icon.style.clipPath = clipPath;
+ });
+ }
+
+ function createSliceClipPath(startAngle, endAngle, radius) {
+ const centerX = radius;
+ const centerY = radius;
+
+ let points = `${centerX}px ${centerY}px`;
+
+ const segments = Math.max(2, Math.ceil(Math.abs(endAngle - startAngle) / 5));
+ const angleStep = (endAngle - startAngle) / segments;
+
+ for (let i = 0; i <= segments; i++) {
+ const angle = ((startAngle + angleStep * i - 90) * Math.PI) / 180;
+ const x = centerX + Math.cos(angle) * radius;
+ const y = centerY + Math.sin(angle) * radius;
+ points += `, ${x}px ${y}px`;
+ }
+
+ points += `, ${centerX}px ${centerY}px`;
+
+ return `polygon(${points})`;
+ }
+
+ function positionThemeWheel() {
+ const buttonRect = themeButton.getBoundingClientRect();
+ const buttonCenterX = buttonRect.left + buttonRect.width / 2;
+ const buttonCenterY = buttonRect.top + buttonRect.height / 2;
+
+ const margin = 0;
+
+ let wheelX = buttonCenterX - themeWheelRadius;
+ let wheelY = buttonCenterY - themeWheelRadius;
+
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ if (wheelX < margin) {
+ wheelX = margin;
+ } else if (wheelX + (themeWheelRadius * 2) > viewportWidth - margin) {
+ wheelX = viewportWidth - (themeWheelRadius * 2) - margin;
+ }
+
+ if (wheelY < margin) {
+ wheelY = margin;
+ } else if (wheelY + (themeWheelRadius * 2) > viewportHeight - margin) {
+ wheelY = viewportHeight - (themeWheelRadius * 2) - margin;
+ }
+
+ themeWheel.style.left = wheelX + 'px';
+ themeWheel.style.top = wheelY + 'px';
+ }
+
+ function hideThemeWheel() {
+ themeWheel.classList.remove('active');
+ }
+
+ function showThemeWheel() {
+ positionThemeWheel();
+ positionThemeIcons();
+ themeWheel.classList.add('active');
+ }
+
+ function toggleThemeWheel() {
+ if (themeWheel.classList.contains('active')) {
+ hideThemeWheel();
+ } else {
+ showThemeWheel();
+ }
+ }
+
+ themeIcons.forEach(icon => {
+ icon.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const theme = icon.getAttribute('data-theme');
+ switchTheme(theme);
+ themeIcons.forEach(icon => {
+ icon.classList.remove('active');
+ })
+ icon.classList.add('active');
+ });
+ });
+
+ themeWheel.addEventListener('mouseleave', hideThemeWheel);
+
+ document.addEventListener('click', (e) => {
+ if (!themeWheel.contains(e.target) && e.target !== themeButton) {
+ hideThemeWheel();
+ }
+ });
+ </script>
+
+ <script>
+ var map;
+ var markers = L.markerClusterGroup();
+
+ function createCustomIcon(color, borderColor = "var(--main-soft-color)") {
+ const svgIcon = `
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+ <path d="M12 1C7.5 1 4 4.5 4 9c0 6.5 8 14 8 14s8-7.5 8-14c0-4.5-3.5-8-8-8zm0 11c-1.5 0-3-1.5-3-3s1.5-3 3-3 3 1.5 3 3-1.5 3-3 3z"
+ fill="${color}"
+ stroke="${borderColor}"
+ stroke-width="1"
+ stroke-linejoin="round"/>
+ </svg>
+ `;
+
+ return L.divIcon({
+ html: svgIcon,
+ className: 'custom-svg-marker',
+ iconSize: [32, 32],
+ iconAnchor: [16, 32],
+ popupAnchor: [0, -32]
+ });
+ }
+
+ function initLocationMap() {
+ map = L.map('map-container').setView([{{ .MapLocationLat }}, {{ .MapLocationLong }}], 4);
+
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
+ zoomControl: false
+ }).addTo(map);
+
+ L.control.zoom({
+ position: 'topright'
+ }).addTo(map);
+
+ map.addLayer(markers);
+ }
+
+ function stringToColor(str) {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+
+ let color = '#';
+ for (let i = 0; i < 3; i++) {
+ const value = (hash >> (i * 8)) & 0xFF;
+ color += ('00' + value.toString(16)).substr(-2);
+ }
+
+ return color;
+ }
+
+ function mapAddMarker(x, y, relativeErrorMeters = 0, title = "", link = "", thumbnail = "") {
+ const titleColor = stringToColor(title);
+
+ if (relativeErrorMeters != 0) {
+ var circle = L.circle([x, y], {
+ color: titleColor,
+ fillColor: titleColor,
+ fillOpacity: 0.15,
+ radius: relativeErrorMeters
+ }).addTo(map);
+ }
+
+ var marker = L.marker([x, y], {
+ icon: createCustomIcon(titleColor),
+ title: title,
+ }).bindPopup(`
+ <div class="flex flex-row justify-center justify-items-center">
+ <img onclick="location.href='${link}';" class="cursor-pointer object-cover rounded-[10%] select-none w-12 h-12 mr-1" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/${thumbnail}.webp">
+ <span><b><a href="${link}">${title}</a></b><br><a href="https://www.openstreetmap.org/#map=13/${x}/${y}">${x}, ${y}</a></span>
+ </div>`);
+
+ markers.addLayer(marker);
+ }
+
+ document.addEventListener('DOMContentLoaded', () => {
+ initLocationMap();
+ {{- range .MapMarkers }}
+ mapAddMarker({{ .Lat }}, {{ .Long }}, {{ .AccuracyMeters }}, {{ .Title }}, {{ .PageLink }}, {{ .Thumbnail }});
+ {{- 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
new file mode 100644
index 0000000..11d0f7b
--- /dev/null
+++ b/views/pages/home-page.html
@@ -0,0 +1,122 @@
+{{ define "body" }}
+<div class="bg-background-light flex flex-row ar-lt-0.6:flex-col w-full h-full inset-shadow-[0_0_0.4rem_black] overflow-y-auto text-base/[2] tracking-wide">
+ <div class="ar-gt-0.6:flex-3/5 justify-center content-start p-8">
+ <div class="flex flex-col justify-start items-start bg-background-dark mb-8 shadow-[0.4rem_0.4rem_0.4rem_black]">
+ <p class="text-3xl/[2] sm:text-4xl/[2] lg:text-5xl/[2] tracking-widest uppercase w-full font-andika italic text-left
+ border-l-4 border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Hymn1 }}</p>
+ <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
+ <p class="text-xl/[2] sm:text-2xl/[2] lg:text-3xl/[2] -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn2 }}</p>
+ <p class="text-xl/[2] sm:text-2xl/[2] lg:text-3xl/[2] -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn3 }}</p>
+ <p class="text-xl/[2] sm:text-2xl/[2] lg:text-3xl/[2] -indent-8 ml-12 p-1">{{ .L.HomePage.Hymn4 }}</p>
+ </div>
+
+ <div class="flex flex-col justify-center items-center">
+ <p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-andika italic text-left
+ border-l-1.5 border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.DidYouKnowThat }}</p>
+ <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
+ <ul class="w-full text-left list-[circle]">
+ <li class="ml-4 mb-1">{{ index .FunFacts 0 }}</li>
+ <li class="ml-4 mb-1">{{ index .FunFacts 1 }}</li>
+ <li class="ml-4">{{ index .FunFacts 2 }}</li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="ar-gt-0.6:flex-2/5 justify-center content-start p-8">
+ <div class="flex flex-col justify-center items-start mb-4">
+ <p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-andika italic text-left
+ border-l-1.5 border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Contacts }}</p>
+ <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
+ <a href="https://t.me/EarlInisMona">
+ <i class="fa-brands fa-telegram w-4 h-4 mr-1"></i>
+ <span class="font-bold">Telegram:</span>
+ <span>@EarlInisMona</span>
+ </a>
+ <a href="https://discord.com/users/356057270951346176">
+ <i class="fa-brands fa-discord w-4 h-4 mr-1"></i>
+ <span class="font-bold">Discord:</span>
+ <span>montferrat</span>
+ </a>
+ <a href="mailto:saya.andy@posteo.com">
+ <i class="fas fa-envelope w-4 h-4 mr-1"></i>
+ <span class="font-bold">E-mail:</span>
+ <span>saya.andy@posteo.com</span>
+ </a>
+ </div>
+
+ <div class="flex relative aspect-square justify-center content-center mb-4">
+ {{- range $_ := iterate .FilledHeartCount }}
+ <div class="home-page-heart z-50">♥</div>
+ {{- end }}
+ {{- range $_ := iterate .OutlineHeartCount }}
+ <div class="home-page-heart z-50">♡</div>
+ {{- end }}
+ <img src="https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/{{ .GifName }}"
+ class="home-page-gif z-49 -rotate-12 max-w-[70%] max-h-[70%] border-4 border-(--background-dark-color) border-inset">
+ </div>
+
+ <script>
+ function shuffleHearts() {
+ vmin = calculateVmin(1);
+ document.querySelectorAll('.home-page-heart').forEach((e) => {
+ let top, left;
+ do {
+ top = Math.floor(rand() * 90) + 5;
+ left = Math.floor(rand() * 90) + 5;
+ } while (top > 30 && top < 70 && left > 30 && left < 70);
+ const opacity = rand() * 0.7 + 0.2;
+ const size = rand() * 3.2 + 0.8;
+ e.style["top"] = `calc(${top}% - ${size}vmin / 2)`;
+ e.style["left"] = `calc(${left}% - ${size}vmin / 2)`;
+ e.style["opacity"] = `${opacity}`;
+ e.style["font-size"] = `${size}vmin`;
+ });
+ }
+
+ let shuffleHeartsResizeTimeout;
+ function shuffleHeartsResizeCb() {
+ clearTimeout(shuffleHeartsResizeTimeout);
+ shuffleHeartsResizeTimeout = setTimeout(shuffleHearts, 300);
+ }
+ window.addEventListener('resize', shuffleHeartsResizeCb);
+
+ document.addEventListener('popout', (e) => {
+ window.removeEventListener('resize', shuffleHeartsResizeCb);
+ shuffleHeartsResizeCb = null;
+ });
+ </script>
+
+ <script>
+ document.addEventListener('htmx:afterRequest', (e) => {
+ if (e.detail.xhr.status == 404 || e.detail.successful != true) {
+ return console.error(e);
+ }
+ if (e.detail.target.id == 'general-page-body') {
+ shuffleHearts();
+ }
+ }, {once: true});
+ </script>
+
+ <div class="flex flex-col justify-center items-center">
+ <p class="text-xl sm:text-2xl lg:text-3xl p-1 w-full font-andika italic text-left
+ border-l-1.5 border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.SidebarDescription }}</p>
+ <hr class="w-full border-t-2 border-dotted border-main-hard mb-1">
+ <div class="w-full grid grid-cols-[1fr_4fr] content-center items-center
+ [&>*:nth-child(4n+2)]:bg-(--background-medium-color)/50 [&>*:nth-child(4n+3)]:bg-(--background-medium-color)/50" style="container-type: inline-size;">
+ <i class="fas fa-home w-full h-full text-[10cqi] content-center text-center text-main-hard"></i>
+ <p class="p-1">{{ .L.HomePage.HomePageDescription }}</p>
+ <i class="fas fa-search w-full h-full text-[10cqi] content-center text-center text-main-hard"></i>
+ <p class="p-1">{{ .L.HomePage.BlogSearchDescription }}</p>
+ <i class="fas fa-map w-full h-full text-[10cqi] content-center text-center text-main-hard"></i>
+ <p class="p-1">{{ .L.HomePage.MarkerMapDescription }}</p>
+ <i class="fas fa-swatchbook w-full h-full text-[10cqi] content-center text-center text-main-hard"></i>
+ <p class="p-1">{{ .L.HomePage.ThemeSwitchDescription }}</p>
+ </div>
+ </div>
+ </div>
+</div>
+{{ end }}
diff --git a/views/pages/language-pick.html b/views/pages/language-pick.html
new file mode 100644
index 0000000..5a9d229
--- /dev/null
+++ b/views/pages/language-pick.html
@@ -0,0 +1,16 @@
+{{ define "body" }}
+<div class="flex flex-col justify-center text-3xl h-[100%]">
+ {{- range .AvailableLanguages }}
+ <hr class="border-t-2 border-dotted border-main-hard">
+ <div class="nth-[2n+1_of_div]:bg-(--background-medium-color)/50 nth-[2n_of_div]:bg-(--background-light-color)/50 p-2.5 flex flex-row justify-center justify-items-center">
+ <a href="/{{ .Name }}" class="w-12 h-12 basis-12 shrink-0 mr-4">
+ <img onclick="return changeUrl('{{ .Name }}');" class="w-full h-full aspect-square cursor-pointer object-cover rounded-md select-none" src="{{ .Flag }}">
+ </a>
+ <a href="/{{ .Name }}" onclick="return changeUrl('{{ .Name }}');" class="cursor-pointer">
+ {{ .Alt }}
+ </a>
+ </div>
+ {{- end }}
+ <hr class="border-t-2 border-dotted border-main-hard">
+</div>
+{{ end }}
diff --git a/views/pages/unsubscribe-page.html b/views/pages/unsubscribe-page.html
new file mode 100644
index 0000000..3876818
--- /dev/null
+++ b/views/pages/unsubscribe-page.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{ .L.UnsubscribePage.Header }} // SAYA TODAY</title>
+<style>
+ html {
+ --noise-image: url(data:image/png;base64,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);
+ }
+</style>
+</head>
+
+<body style="width: 100dvw; height: 100dvh; display: flex; flex-direction: column; position: fixed">
+
+ <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.1)'></div>
+
+ <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;">
+ <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 2rem;">{{ .StatusEmoji }}</p>
+ </div>
+
+ <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;">
+ <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 1.5rem;">{{ .StatusText }}</p>
+ </div>
+
+ <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.2)'></div>
+
+</body>
+
+</html>
diff --git a/views/pages/user-page.html b/views/pages/user-page.html
new file mode 100644
index 0000000..7d39825
--- /dev/null
+++ b/views/pages/user-page.html
@@ -0,0 +1,205 @@
+{{ define "body" }}
+<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4">
+ <form id="email-form" class="w-full mb-2 flex flex-col relative crossable"
+ hx-get="/api/v1/email/is-in-verification" hx-target="#email-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this">
+ <div class="flex flex-row w-full mb-4 z-21">
+ <div class="flex flex-col w-full sm:flex-3/5 grow-0">
+ <label class="block text-main-medium font-bold mb-1 z-22">
+ {{ .L.UserProfile.EmailHeader }}
+ </label>
+ <input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22"
+ id="email-input" name="email" type="text" value="{{ .Email }}" placeholder="{{ .L.UserProfile.TypeEmail }}" maxlength="64"
+ data-validated-email="{{ .Email }}" onchange="checkIfValidated(this);">
+ </div>
+ </div>
+ <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
+ hx-post="/api/v1/email/send-verification-code" hx-target="#email-message" hx-swap="outerHTML" hx-indicator="#email-form">
+ {{ .L.UserProfile.SendCodeButton }}
+ </button>
+ </form>
+ <div id="email-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
+
+ <hr class="my-2 border-t-4 border-dotted border-main-hard">
+
+ <form id="email-verification-form" class="w-full mb-2 flex flex-col relative hidden crossable"
+ {{ if .EmailCode }}hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"{{ end }}>
+ <div class="flex flex-row w-full mb-4 z-21">
+ <div class="flex flex-col w-full sm:flex-2/5 grow-0">
+ <label class="block text-main-medium font-bold ml-1 z-22">
+ {{ .L.UserProfile.VerificationCodeHeader }}
+ </label>
+ <input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22"
+ name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16" autocapitalize="characters">
+ </div>
+ </div>
+ <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
+ hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-indicator="#email-verification-form">
+ {{ .L.UserProfile.VerifyButton }}
+ </button>
+ </form>
+ <div id="verification-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
+</div>
+
+<hr class="my-4 border-t-4 border-dotted border-main-hard">
+
+<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4">
+ <form id="subs-form" class="w-full mb-2 flex flex-col relative crossable">
+ <div class="flex flex-col w-full mb-4 z-21">
+ <label class="block text-main-medium font-bold mb-1 z-22">
+ {{ .L.UserProfile.SubscriptionHeader }}
+ </label>
+ <div class="flex flex-col sm:flex-row w-full">
+ <div class="m-1 w-1/3">
+ <label><input class="mr-1" type="radio" id="tagsNone" name="tags" value="none" {{ if eq .TagsPicked "none" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsNone }}</label>
+ </div>
+ <div class="m-1 w-1/3">
+ <label><input class="mr-1" type="radio" id="tagsAll" name="tags" value="all" {{ if eq .TagsPicked "all" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsAll }}</label>
+ </div>
+ <div class="m-1 w-1/3">
+ <label><input class="mr-1" type="radio" id="tagsSpecific" name="tags" value="specific" {{ if eq .TagsPicked "specific" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsSpecific }}</label>
+ </div>
+ </div>
+ <div id="pick-tags-form" class="flex flex-col w-full relative crossable">
+ <input type="text" class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22"
+ list="existing-tags" onchange="addTag(this.value);" onkeydown="if (event.keyCode === 13) {addTag(this.value); this.value=''; return false;}">
+ <datalist id="existing-tags">
+ {{- range .ExistingTags }}
+ <option value="{{ .Name }}">
+ {{- end }}
+ </datalist>
+ <div id="tags-picked-list" class="bg-background-dark flex flex-wrap inset-shadow-[0_0_0.4rem_black] p-2 m-4 w-full">
+ {{- range .TagsPickedList }}
+ <div id="tagPicked{{ . }}" class="bg-main-hard text-background-light m-2 flex flex-row">
+ <p class="py-1 px-2">{{ . }}</p>
+ <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked{{ . }}'));">✘</p>
+ <input type="hidden" name="tags_picked" value="{{ . }}">
+ </div>
+ {{- end }}
+ </div>
+ </div>
+ <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22"
+ hx-put="/api/v1/subs" hx-target="#subs-message" hx-swap="outerHTML" hx-indicator="#subs-form">
+ {{ .L.UserProfile.SaveButton }}
+ </button>
+ </div>
+ </form>
+ <div id="subs-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div>
+</div>
+
+<script>
+htmx.on("htmx:beforeSwap", (e) => {
+ if (e.detail.xhr.status === 422) {
+ e.detail.shouldSwap = true;
+ e.detail.isError = false;
+ }
+});
+
+var formAllowTimeout;
+var verificationHideTimeout;
+
+var url = new URL(window.location.href);
+url.searchParams.delete('email_code');
+window.history.pushState({}, '', url.toString());
+
+htmx.on("htmx:afterSwap", (e) => {
+ emailMessage = htmx.find("#email-message");
+ verificationMessage = htmx.find("#verification-message");
+ emailForm = htmx.find("#email-form");
+ emailInput = htmx.find("#email-input");
+ emailVerificationForm = htmx.find("#email-verification-form");
+ subsForm = htmx.find("#subs-form");
+ tryAgainTimeDiff = parseInt(emailMessage.dataset.strikedEndTime || "0") - new Date().getTime();
+ verificationWaitingTimeDiff = parseInt(emailMessage.dataset.codeExpiryTime || "0") - new Date().getTime();
+
+ if (tryAgainTimeDiff > 0) {
+ emailForm.classList.add("disabled");
+ formAllowTimeout = setTimeout(() => {
+ emailForm.classList.remove("disabled");
+ emailMessage.classList.add("hidden");
+ }, tryAgainTimeDiff);
+ } else {
+ emailForm.classList.remove("disabled");
+ }
+
+ if (verificationWaitingTimeDiff > 0) {
+ verificationHideTimeout = setTimeout(() => emailVerificationForm.classList.add("hidden"), verificationWaitingTimeDiff);
+ emailVerificationForm.classList.remove("hidden");
+ } else {
+ emailVerificationForm.classList.add("hidden");
+ }
+
+ if ((verificationMessage.dataset.hideVerificationPanel || "false") == "true") {
+ emailVerificationForm.classList.add("hidden");
+ }
+
+ if (emailInput.dataset.validatedEmail != "") {
+ subsForm.classList.remove("disabled");
+ } else {
+ subsForm.classList.add("disabled");
+ }
+});
+
+function checkIfValidated(elem) {
+ if ((elem.dataset.validatedEmail != "") && (elem.value == elem.dataset.validatedEmail)) {
+ elem.classList.add("!bg-green-300/20");
+ } else {
+ elem.classList.remove("!bg-green-300/20");
+ }
+}
+
+checkIfValidated(htmx.find("#email-input"));
+
+function toggleTagsPick(elem) {
+ const pickTagsForm = htmx.find("#pick-tags-form");
+ if (elem.value === "specific" && elem.checked) {
+ pickTagsForm.classList.remove("disabled");
+ } else {
+ pickTagsForm.classList.add("disabled");
+ }
+}
+
+toggleTagsPick(htmx.find("#tagsSpecific"));
+
+function addTag(name) {
+ name = name.trim();
+ const subsMessage = htmx.find("#subs-message");
+ const existingTags = htmx.findAll(htmx.find("#existing-tags"), "option");
+ found = [...existingTags].find(opt => opt.value===name);
+ if (!found) {
+ subsMessage.classList.remove("hidden");
+ subsMessage.innerHTML = "{{ .L.UserProfile.TagDoesNotExist }}".replace("{}", name);
+ return;
+ }
+
+ const tagsPickedList = htmx.find("#tags-picked-list");
+ const subscribedTags = htmx.findAll(tagsPickedList, "div input");
+ found = [...subscribedTags].find(opt => opt.value===name);
+ if (found) {
+ subsMessage.classList.remove("hidden");
+ subsMessage.innerHTML = "{{ .L.UserProfile.TagAlreadyAdded }}".replace("{}", name);
+ return;
+ }
+
+ tagsPickedList.innerHTML += `
+ <div id="tagPicked${name}" class="bg-main-hard text-background-light m-2 flex flex-row">
+ <p class="py-1 px-2">${name}</p>
+ <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked${name}'));">✘</p>
+ <input type="hidden" name="tags_picked" value="${name}">
+ </div>
+ `;
+}
+
+htmx.on("htmx:configRequest", (e) => {
+ const params = e.detail.parameters;
+
+ if (Array.isArray(params.tags_picked)) {
+ tags_join = params.tags_picked[0] || "";
+ for (i = 1; i < params.tags_picked.length; i++) {
+ tags_join += ",";
+ tags_join += params.tags_picked[i];
+ }
+ params.tags_picked = tags_join;
+ }
+})
+</script>
+{{ end }} \ No newline at end of file
diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html
new file mode 100644
index 0000000..8d555da
--- /dev/null
+++ b/views/partials/blog-page-like-button.html
@@ -0,0 +1,6 @@
+<button
+ hx-put="/api/v1/like" hx-vals='{"like": {{ not .Liked }}}' hx-target="this" hx-swap="outerHTML" hx-trigger="click"
+ class="text-sm/[0.9] shrink-0 font-andika text-left px-1 py-0.5 {{ if .Liked }}bg-main-soft hover:bg-main-medium text-background-dark border-inset{{ else }}bg-main-hard hover:bg-main-medium text-background-light border-outset{{ end }} cursor-pointer border-2 border-background-dark">
+ <i class="fas fa-thumbs-up w-3 h-3"></i>
+ <span class="italic ml-0.5 text-background-light">({{ .LikedCount }})</span>
+</button> \ No newline at end of file
diff --git a/views/partials/catalogue-blog-card-tags.html b/views/partials/catalogue-blog-card-tags.html
new file mode 100644
index 0000000..374314a
--- /dev/null
+++ b/views/partials/catalogue-blog-card-tags.html
@@ -0,0 +1 @@
+{{ range $i, $tag := .Tags }}{{ if ne $i 0 }}, {{ end }}<a hx-trigger="click" hx-get="/api/v1/blog-search" hx-target=".blog-cards" hx-swap="innerHTML" hx-trigger="load" hx-include=".tags-list" class="hx-anchor cursor-pointer" onclick="pickedTagSetHrefParams({{ $tag }})">{{ $tag }}</a>{{ end }} \ No newline at end of file
diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html
new file mode 100644
index 0000000..b50f419
--- /dev/null
+++ b/views/partials/catalogue-blog-cards.html
@@ -0,0 +1,27 @@
+{{- range .BlogPages }}
+<hr class="first-of-type:hidden my-1 border-t-2 border-dotted border-main-hard">
+<div class="m-1 flex flex-row justify-center justify-items-center">
+ <a href="{{ .ArticleLink }}" class="w-24 h-24 basis-24 shrink-0 my-2.5 mr-2">
+ <img onclick="return changeUrl('{{ .ArticleLink }}');" class="w-full h-full aspect-square cursor-pointer object-cover rounded-lg select-none" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp">
+ </a>
+ <div class="grow my-0.5 mx-1 flex flex-col justify-center">
+ <div class="flex flex-row gap-2 max-w-full">
+ <a href="{{ .ArticleLink }}" onclick="return changeUrl('{{ .ArticleLink }}');" class="grow text-base cursor-pointer">
+ <span class="font-extrabold">{{ .Title }}</span>
+ <span class="font-extrabold select-none text-secondary">//</span>
+ <span class="italic text-secondary">{{ .ActionDate }}</span>
+ </a>
+ <p class="basis-56 text-base italic text-secondary text-right">{{ .PublishedTime }}</p>
+ </div>
+ <p class="text-base text-main-hard">{{ .ShortDescription }}</p>
+ <p class="text-base text-secondary italic">
+ <i class="fas fa-thumbs-up w-4 h-4"></i>
+ <span>{{ .LikeCount }}</span>
+ <span class="font-extrabold select-none">//</span>
+ <i class="fas fa-eye w-4 h-4"></i>
+ <span>{{ .ViewCount }}</span>
+ </p>
+ <p class="text-base text-secondary">{{ $.L.TagsLabel }} {{ template "catalogue-blog-card-tags.html" . }}</p>
+ </div>
+</div>
+{{- end }} \ No newline at end of file
diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html
new file mode 100644
index 0000000..c3aa3eb
--- /dev/null
+++ b/views/partials/general-page-body.html
@@ -0,0 +1,2 @@
+<title>{{ .Title }} // SAYA TODAY</title>
+{{ block "body" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/general-page-bottom-embeds.html b/views/partials/general-page-bottom-embeds.html
new file mode 100644
index 0000000..4c36b26
--- /dev/null
+++ b/views/partials/general-page-bottom-embeds.html
@@ -0,0 +1 @@
+{{ block "bottom-embeds" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/general-page-footer.html b/views/partials/general-page-footer.html
new file mode 100644
index 0000000..9b91bf0
--- /dev/null
+++ b/views/partials/general-page-footer.html
@@ -0,0 +1,7 @@
+{{ block "footer" . }}{{ end }}
+<p class="grow content-center text-base/[1] font-andika italic text-right text-secondary mr-1">
+ All the photos on <a href="https://saya.today/">saya.today</a> are licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a> by <a href="https://t.me/EarlInisMona">Saya Andy</a> © {{ .PublishedYear }}
+</p>
+<img class="max-w-4 max-h-4 self-center" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="CC">
+<img class="max-w-4 max-h-4 self-center" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="BY">
+<img class="max-w-4 max-h-4 self-center" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="SA"> \ No newline at end of file
diff --git a/views/partials/general-page-header.html b/views/partials/general-page-header.html
new file mode 100644
index 0000000..a29d40b
--- /dev/null
+++ b/views/partials/general-page-header.html
@@ -0,0 +1,6 @@
+<div {{ if .PublishedDate }} hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="#published-date" hx-swap="innerHTML" hx-trigger="load" {{ end }}
+ class="flex flex-row mb-2">
+ <p class="text-4xl font-andika font-light italic text-shadow-[0_2px_2px_var(--main-soft-color)] text-left mt-auto">{{ .Title }}</p>
+ <p id="published-date" class="grow text-2xl font-andika italic text-secondary text-right mt-auto">{{ .PublishedDate }}</p>
+</div>
+{{ block "header" . }}{{ end }}
diff --git a/views/partials/general-page-top-embeds.html b/views/partials/general-page-top-embeds.html
new file mode 100644
index 0000000..1407b50
--- /dev/null
+++ b/views/partials/general-page-top-embeds.html
@@ -0,0 +1 @@
+{{ block "top-embeds" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/personal-page-status.html b/views/partials/personal-page-status.html
new file mode 100644
index 0000000..a4e47c2
--- /dev/null
+++ b/views/partials/personal-page-status.html
@@ -0,0 +1,12 @@
+<div id="{{ .StatusId }}" class='z-26 w-full inset-shadow-[0_0_0.4rem_black]
+ {{if eq .Status "OK"}}bg-green-700 text-green-100
+ {{else if eq .Status "Failed"}}bg-red-700 text-red-100
+ {{else}}bg-background-dark text-main-soft{{end}}
+ {{if eq .Message ""}}hidden{{end}}
+ py-2 px-4 text-center'
+ {{- range $_, $val := .DataAttributes }}
+ {{ $val }}
+ {{- end }}
+ >
+ {{ .Message }}
+</div> \ No newline at end of file