37 files changed, 1648 insertions, 450 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml index 53e307e..a180d39 100644 --- a/.github/workflows/build-and-deploy-prod.yml +++ b/.github/workflows/build-and-deploy-prod.yml @@ -75,4 +75,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} + ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml index 104d9ac..0aea0a4 100644 --- a/.github/workflows/build-and-deploy-stage.yml +++ b/.github/workflows/build-and-deploy-stage.yml @@ -86,4 +86,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} + ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} @@ -1,22 +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-web . +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-web /app/sayana-web +COPY --from=build-stage /builddir/migrations /app/migrations COPY --from=build-stage /builddir/static /app/static COPY --from=build-stage /builddir/views /app/views COPY --from=build-stage /builddir/locale/*.yaml /app/locale/ COPY --from=build-stage /builddir/config/config*.yaml /app/config/ -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata sqlite ENTRYPOINT /app/sayana-web -c /app/config/config.${ENVIRONMENT}.yaml diff --git a/config/config.go b/config/config.go index 8c0321e..7438fe1 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,7 @@ type Config struct { BlogPages BlogPagesConfig `json:"BlogPages" yaml:"blogPages" 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"` } type BlogPagesConfig struct { @@ -39,6 +40,20 @@ type AvailableLanguageConfig struct { LocFile string `json:"LocFile" yaml:"locFile" validate:"required,filepath"` } +type AuthConfig struct { + Salt string `json:"Salt" yaml:"salt" validate:"required"` + Db DbConfig `json:"Db" yaml:"db" validate:"required"` +} + +type DbConfig struct { + Type string `json:"Type" yaml:"type" validate:"required,oneof=sqlite3"` + Cfg Sqlite3Config `json:"Config" yaml:"config"` +} + +type Sqlite3Config struct { + DSN string `json:"DSN" yaml:"dsn" validate:"required"` +} + func LoadConfig(path string, config *Config) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/config/config.local.yaml b/config/config.local.yaml new file mode 100644 index 0000000..0b48e47 --- /dev/null +++ b/config/config.local.yaml @@ -0,0 +1,26 @@ +logLevel: debug +blogPages: + storage: + type: b2 + config: + bucketName: sayana-pages + region: eu-central-003 + prefix: 'stage-' + keyID: '${B2_KEY_ID}' + applicationKey: '${B2_APPLICATION_KEY}' +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' + salt: '123' diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 2d050bf..e1294c0 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -18,3 +18,9 @@ availableLanguages: alt: English flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg locFile: localization.en.yaml +auth: + db: + type: sqlite3 + config: + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' + salt: '${AUTH_SALT}' diff --git a/config/config.stage.yaml b/config/config.stage.yaml index a34a33e..30233c1 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -18,3 +18,9 @@ availableLanguages: alt: English flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg locFile: localization.en.yaml +auth: + db: + type: sqlite3 + config: + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' + salt: '${AUTH_SALT}' diff --git a/deploy/playbook.yml b/deploy/playbook.yml index ca22ac3..dfceaf7 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -4,6 +4,10 @@ remote_user: svc_github tasks: + - name: Create a volume + community.docker.docker_volume: + name: "{{ saya_today_web_name }}-volume" + - name: Deploy saya-today-web Docker Container community.docker.docker_container: name: "{{ saya_today_web_name }}" @@ -13,10 +17,13 @@ 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 }}" network_mode: caddy networks: - name: caddy ipv4_address: "{{ saya_today_web_ipv4_address }}" + volumes: + - "{{ saya_today_web_name }}-volume:/data:rw" restart_policy: unless-stopped no_log: true ... @@ -1,33 +1,36 @@ 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/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/gofiber/template v1.8.3 // indirect - github.com/gofiber/template/html/v2 v2.1.3 // indirect - github.com/gofiber/utils v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/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/stretchr/testify v1.10.0 // 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 + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) @@ -1,9 +1,14 @@ 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/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/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-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 +17,56 @@ 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/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/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/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/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= +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/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 5322b84..7b745a8 100644 --- a/internal/b2/client.go +++ b/internal/b2/client.go @@ -84,6 +84,7 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { PublishedTime: publishedTime, Thumbnail: attrs.Info["thumbnail"], Tags: strings.Split(attrs.Info["tags"], ","), + Geolocation: attrs.Info["geolocation"], }, }) } diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go index 3c00a7f..1a521c0 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -16,7 +16,6 @@ type Metadata struct { Thumbnail string `yaml:"thumbnail"` Tags []string `yaml:"tags"` Geolocation string `yaml:"geolocation"` - Timezone string `yaml:"timezone"` } func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go index 850cd95..17a6f2b 100644 --- a/internal/lightgallery/html_renderer.go +++ b/internal/lightgallery/html_renderer.go @@ -79,58 +79,48 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [ 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)" - }], + responsive: "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp 384, https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp 672, https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp 960, https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp 1440, https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp 1920", thumb: - "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp", + "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions"> - <p class="grow !text-[1vmax]/[0.9] text-left font-spectral text-main-dark">%s</p> - <p class="!text-[1vmax]/[0.9] text-right font-spectral text-secondary">%s</p> + <p class="grow !text-[1vmax]/[1] text-left font-spectral text-main-dark">%s</p> + <p class="!text-[1vmax]/[1] text-right font-spectral text-secondary">%s</p> </div>`+"`"+` - }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00"))) + }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00"))) } w.WriteString(fmt.Sprintf(` <script> -function 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: () => false, - slideDelay: 0, - plugins: [lgZoom, lgThumbnail], - thumbWidth: 160, - thumbHeight: "10vmin", - thumbMargin: 4 - }); +function createLightGallery%s() { + const $lgContainer = document.getElementById('lg-%s'); + const config = { + container: $lgContainer, + dynamic: true, + dynamicEl: [%s], + width: "100%%", + height: "50vmin", + hash: false, + closable: false, + showMaximizeIcon: true, + appendSubHtmlTo: ".lg-sub-html", + isMobile: () => false, + slideDelay: 0, + plugins: [lgZoom, lgThumbnail], + thumbWidth: calculateVmin(10), + thumbHeight: "10vmin", + thumbMargin: 4 + }; + const inlineGallery = lightGallery($lgContainer, config); - setTimeout(() => { - inlineGallery.openGallery(); - }, 200); -} + setTimeout(() => { + inlineGallery.openGallery(); + }, 200); -document.addEventListener('DOMContentLoaded', createLightLibrary%s); + galleryMap.set(inlineGallery, createLightGallery%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)) +document.addEventListener('DOMContentLoaded', createLightGallery%s); +</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID)) } return ast.WalkContinue, 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..e22fabe --- /dev/null +++ b/internal/router/api-v1-blog-search.go @@ -0,0 +1,113 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + 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 []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + sort := c.Query("sort") + lang := c.Query("lang") + tz := c.Query("tz") + + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + } + + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz)) + } + + pages, err := b2Client.Scan(lang + "/") + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err)) + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, _ := 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), + }) + 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-like.go b/internal/router/api-v1-like.go new file mode 100644 index 0000000..327dafa --- /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() { + 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-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..81d7641 --- /dev/null +++ b/internal/router/client-cache.go @@ -0,0 +1,229 @@ +package router + +import ( + "database/sql" + "encoding/base64" + "fmt" + "log/slog" + "strings" + "sync" + + "golang.org/x/crypto/argon2" +) + +type PageLike struct { + PageRef string + UserId string +} + +type ClientCache struct { + hashMap map[string]string + hashMapMutex sync.RWMutex + + likePageMap 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) + } + + rows, err := tx.Query("select * from blog_likes;") + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err) + } + + likePageMap := 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{}) + pageMutexMap[pageRef] = &sync.RWMutex{} + } + likePageMap[pageRef][userIdString] = struct{}{} + } + + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("fail to commit transaction in db: %w", err) + } + + return &ClientCache{ + hashMap: make(map[string]string), + likePageMap: likePageMap, + 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) + } + + if _, err = tx.Exec("delete from blog_likes;"); err != nil { + tx.Rollback() + return fmt.Errorf("fail to truncate table blog_likes: %w", err) + } + + userIdBytes := make(map[string][]byte) + + sqlStatement := fmt.Sprintf(` + INSERT OR IGNORE INTO blog_likes (page_ref, user_id) + VALUES %s(?, ?); + `, strings.Repeat("(?, ?), ", 99)) + sqlStatementVars := make([]any, 0, 200) + + for pageRef, userSet := range c.likePageMap { + 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 like pairs into db", slog.String("error", err.Error())) + } + + sqlStatementVars = make([]any, 0, 200) + } + } + + if len(sqlStatementVars) > 0 { + sqlStatement = fmt.Sprintf(` + INSERT OR IGNORE INTO blog_likes (page_ref, user_id) + VALUES %s(?, ?); + `, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1)) + + if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { + slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error())) + } + } + + return tx.Commit() +} + +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 +} diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go new file mode 100644 index 0000000..bafb0d2 --- /dev/null +++ b/internal/router/lang-blog-title.go @@ -0,0 +1,82 @@ +package router + +import ( + "bytes" + "fmt" + "html/template" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" + "github.com/yuin/goldmark" +) + +func init() { + tm.Add("blog-page", "views/layouts/general-page.html", "views/pages/blog-page.html") +} + +func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + ip := c.IP() + slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path())) + + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title")) + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title"))) + } + + geolocationParts := strings.Split(metadata.Geolocation, " ") + var x, y, areaError string + if len(geolocationParts) >= 2 { + x = geolocationParts[0] + y = geolocationParts[1] + } + if len(geolocationParts) >= 3 { + areaError = geolocationParts[2] + } + + content, err := tm.Render("blog-page", fiber.Map{ + "Title": metadata.Title, + "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"), + "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()), + "ParsedMarkdown": template.HTML(parsedMarkdown), + "MapLocationX": x, + "MapLocationY": y, + "MapLocationAreaMeters": areaError, + "Lang": lang, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Send(content) + } +} + +func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { + metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md") + if err != nil { + return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) + } + + var buf bytes.Buffer + if err := md.Convert(markdown, &buf); err != nil { + return nil, "", fmt.Errorf("convert source context from md to html: %w", err) + } + + return metadata, buf.String(), nil +} diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go new file mode 100644 index 0000000..dd3f079 --- /dev/null +++ b/internal/router/lang-blog.go @@ -0,0 +1,93 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html") +} + +func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + querySort := c.Query("sort") + if querySort == "" { + querySort = "publicationDateDesc" + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) + matches := re.FindAllStringSubmatch(decodedQuery, -1) + + queryTags := make([]string, 0, len(matches)) + for _, match := range matches { + queryTags = append(queryTags, string(match[1])) + } + + pages, err := b2Client.Scan(lang + "/") + status := fiber.StatusOK + if err != nil { + status = fiber.StatusPartialContent + pages = []*b2.BlogPage{} + } + + tagsMap := make(map[string]int) + for _, page := range pages { + 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("path", c.Path())) + + type Tag struct { + Name string `json:"Name" yaml:"name"` + Count int `json:"Count" yaml:"count"` + } + + tagsArray := make([]Tag, 0, len(tagsMap)) + for tag, count := range tagsMap { + tagsArray = append(tagsArray, Tag{tag, count}) + } + slices.SortFunc(tagsArray, func(a Tag, b Tag) int { + return strings.Compare(a.Name, b.Name) + }) + + content, err := tm.Render("blog-catalogue", fiber.Map{ + "QuerySort": querySort, + "QueryTags": strings.Join(queryTags, ","), + "Tags": tagsArray, + "Lang": lang, + "L": l[lang], + "PublishedYear": "2025", + "Title": l[lang].BlogSearch.Header, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("path", c.Path()), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Status(status).Send(content) + } +} diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go new file mode 100644 index 0000000..e94d756 --- /dev/null +++ b/internal/router/lang-map.go @@ -0,0 +1,85 @@ +package router + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("global-map", "views/pages/global-map.html") +} + +func Lang_Map(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + pages, err := b2Client.Scan(lang + "/") + status := fiber.StatusOK + if err != nil { + status = fiber.StatusPartialContent + pages = []*b2.BlogPage{} + } + + type MapMarker struct { + Title string `json:"Title"` + PageLink string `json:"PageLink"` + Lat float64 `json:"Lat"` + Long float64 `json:"Long"` + AccuracyMeters int64 `json:"AccuracyMeters"` + Thumbnail string `json:"Thumbnail"` + } + + mapMarkers := make([]*MapMarker, 0, len(pages)) + for _, page := range pages { + geolocationParts := strings.Split(page.Metadata.Geolocation, " ") + if len(geolocationParts) < 2 { + continue + } + + var x, y float64 + var areaError int64 + if len(geolocationParts) >= 2 { + x, _ = strconv.ParseFloat(geolocationParts[0], 64) + y, _ = strconv.ParseFloat(geolocationParts[1], 64) + } + if len(geolocationParts) >= 3 { + areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64) + } + + mapMarkers = append(mapMarkers, &MapMarker{ + Title: page.Metadata.Title, + PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName), + Lat: x, + Long: y, + AccuracyMeters: areaError, + Thumbnail: page.Metadata.Thumbnail, + }) + } + + content, err := tm.Render("global-map", fiber.Map{ + "Lang": lang, + "L": l[lang], + "MapLocationLat": 45.4507, + "MapLocationLong": 68.8319, + "MapMarkers": mapMarkers, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Status(status).Send(content) + } +} diff --git a/internal/router/root.go b/internal/router/root.go new file mode 100644 index 0000000..f4a0692 --- /dev/null +++ b/internal/router/root.go @@ -0,0 +1,27 @@ +package router + +import ( + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("index", "views/index.html") +} + +func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + content, err := tm.Render("index", fiber.Map{ + "AvailableLanguages": localeCfg, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Send(content) + } +} diff --git a/internal/router/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/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go index 5effcb3..f89d0c8 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -22,7 +22,7 @@ type TemplateManagerTemplates struct { Files []string } -func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, error) { +func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { templateMap := make(map[string]templateManagerRender) for _, tmplStruct := range templates { @@ -37,7 +37,6 @@ func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, Main: filepath.Base(tmplStruct.Files[0]), Tmpl: tmpl, } - } return &TemplateManager{ @@ -55,3 +54,24 @@ func (tm *TemplateManager) Render(name string, data interface{}) ([]byte, error) err := tmpl.Tmpl.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("").Funcs(template.FuncMap{ + "contains": strings.Contains, + }) + + tmpl, err := tmpl.ParseFiles(files...) + if err != nil { + return fmt.Errorf("failed to add template into manager: %w", err) + } + + tm.templates[name] = templateManagerRender{ + Main: filepath.Base(files[0]), + Tmpl: tmpl, + } + return nil +} diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index 879fce8..48ef9da 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -7,3 +7,6 @@ BlogSearch: ActionDateOrdered: 'Action Date' PublicationDateOrdered: 'Publication Date' ChooseAllTags: 'Choose All' +GlobalMap: + Header: 'Global Map' +LikeButton: 'Like!' diff --git a/locale/localization.go b/locale/localization.go index d5c6b48..b557e08 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -10,6 +10,8 @@ import ( type LocaleConfig struct { TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` + GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` + LikeButton string `yaml:"LikeButton" json:"LikeButton"` } type BlogSearchConfig struct { @@ -22,6 +24,10 @@ type BlogSearchConfig struct { ChooseAllTags string `yaml:"ChooseAllTags" json:"ChooseAllTags"` } +type GlobalMapConfig struct { + Header string `yaml:"Header" json:"Header"` +} + func LoadConfig(path string, config *LocaleConfig) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index 786665d..d6e9fe4 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -7,3 +7,6 @@ BlogSearch: ActionDateOrdered: 'дате действия' PublicationDateOrdered: 'времени публикации' ChooseAllTags: 'Выбрать все' +GlobalMap: + Header: 'Глобальная карта' +LikeButton: 'Нраица!' @@ -1,32 +1,30 @@ package main import ( - "bytes" + "database/sql" + "errors" "flag" - "fmt" - "html/template" - "log" "log/slog" - "net/url" "os" - "regexp" - "slices" - "strconv" - "strings" - "time" + "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/router" "github.com/SayaAndy/saya-today-web/internal/tailwind" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/redirect" + "github.com/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/golang-migrate/migrate/v4/source/file" + _ "github.com/mattn/go-sqlite3" ) var ( @@ -62,6 +60,32 @@ 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())) @@ -78,18 +102,9 @@ func main() { localization[lang.Name] = localeCfg } - tm, err := templatemanager.NewTemplateManager([]templatemanager.TemplateManagerTemplates{ - {Name: "blog-page", Files: []string{"views/layouts/general-page.html", "views/pages/blog-page.html"}}, - {Name: "blog-catalogue", Files: []string{"views/layouts/general-page.html", "views/pages/blog-catalogue.html"}}, - {Name: "index", Files: []string{"views/index.html"}}, - {Name: "catalogue-blog-cards", Files: []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}}, + app := fiber.New(fiber.Config{ + ProxyHeader: "X-Forwarded-For", }) - if err != nil { - slog.Error("fail to initialize template manager", slog.String("error", err.Error())) - os.Exit(1) - } - - app := fiber.New(fiber.Config{}) app.Use(redirect.New(redirect.Config{ Rules: map[string]string{ @@ -99,267 +114,43 @@ func main() { StatusCode: 301, })) - app.Get("/", func(c *fiber.Ctx) error { - content, err := tm.Render("index", fiber.Map{ - "AvailableLanguages": cfg.AvailableLanguages, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") - } - - return c.Type("html").Send(content) - }) - - app.Get("/:lang/blog", func(c *fiber.Ctx) error { - lang := c.Params("lang") - if !slices.Contains(availableLanguages, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - querySort := c.Query("sort") - if querySort == "" { - querySort = "publicationDateDesc" - } - - encodedQuery := c.Request().URI().QueryString() - re, err := regexp.Compile(`tags\[\]=([\w]+)`) - if err != nil { - slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") - } - decodedQuery, err := url.QueryUnescape(string(encodedQuery)) - matches := re.FindAllStringSubmatch(decodedQuery, -1) - - queryTags := make([]string, 0, len(matches)) - for _, match := range matches { - queryTags = append(queryTags, string(match[1])) - } - - pages, err := b2Client.Scan(lang + "/") - status := fiber.StatusOK - if err != nil { - status = fiber.StatusPartialContent - pages = []*b2.BlogPage{} - } - - tagsMap := make(map[string]int) - for _, page := range pages { - slog.Debug("enlist page for catalogue", slog.Any("page", page), slog.String("endpoint", "/"+lang+"/blog")) - for _, tag := range page.Metadata.Tags { - tagsMap[tag]++ - } - } - - type Tag struct { - Name string `json:"Name" yaml:"name"` - Count int `json:"Count" yaml:"count"` - } - - tagsArray := make([]Tag, 0, len(tagsMap)) - for tag, count := range tagsMap { - tagsArray = append(tagsArray, Tag{tag, count}) - } - slices.SortFunc(tagsArray, func(a Tag, b Tag) int { - return strings.Compare(a.Name, b.Name) - }) - - content, err := tm.Render("blog-catalogue", fiber.Map{ - "QuerySort": querySort, - "QueryTags": strings.Join(queryTags, ","), - "Tags": tagsArray, - "Lang": lang, - "L": localization[lang], - "PublishedYear": "2025", - "Title": localization[lang].BlogSearch.Header, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") - } - - return c.Type("html").Status(status).Send(content) - }) - - app.Get("/:lang/blog/:title", func(c *fiber.Ctx) error { - lang := c.Params("lang") - if !slices.Contains(availableLanguages, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - metadata, parsedMarkdown, err := readBlogPost(lang + "/" + c.Params("title")) - if err != nil { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title"))) - } - - geolocationParts := strings.Split(metadata.Geolocation, " ") - var x, y, areaError string - if len(geolocationParts) >= 2 { - x = geolocationParts[0] - y = geolocationParts[1] - } - if len(geolocationParts) >= 3 { - areaError = geolocationParts[2] - } - - content, err := tm.Render("blog-page", fiber.Map{ - "Title": metadata.Title, - "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"), - "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()), - "ParsedMarkdown": template.HTML(parsedMarkdown), - "MapLocationX": x, - "MapLocationY": y, - "MapLocationAreaMeters": areaError, - "Lang": lang, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") - } - - return c.Type("html").Send(content) - }) - - app.Get("/api/v1/tz", 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)) - }) - - app.Get("/api/v1/blog-search", func(c *fiber.Ctx) error { - sort := c.Query("sort") - lang := c.Query("lang") - tz := c.Query("tz") - - if !slices.Contains(availableLanguages, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) - } - - loc, err := time.LoadLocation(tz) - if err != nil { - loc = time.UTC - slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz)) - } - - pages, err := b2Client.Scan(lang + "/") - if err != nil { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err)) - } - - encodedQuery := c.Request().URI().QueryString() - re, err := regexp.Compile(`tags\[\]=([\w]+)`) - if err != nil { - slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") - } - decodedQuery, err := url.QueryUnescape(string(encodedQuery)) - matches := re.FindAllStringSubmatch(decodedQuery, -1) + router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt)) + if err != nil { + slog.Error("fail to initialize cache", slog.String("error", err.Error())) + os.Exit(1) + } - tags := make([]string, 0, len(matches)) - for _, match := range matches { - tags = append(tags, string(match[1])) - } + app.Get("/", router.Root(cfg.AvailableLanguages)) + app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client)) + app.Get("/:lang/blog", router.Lang_Blog(localization, availableLanguages, b2Client)) + app.Get("/:lang/blog/:title", router.Lang_Blog_Title(localization, availableLanguages, b2Client, md)) + 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)) - pageMeta := make([]fiber.Map, 0, len(pages)) - for _, page := range pages { - for _, tag := range page.Metadata.Tags { - if len(tags) == 0 || slices.Contains(tags, tag) { - pageMeta = append(pageMeta, fiber.Map{ - "Link": page.Link, - "ArticleLink": "/" + lang + "/blog/" + page.FileName, - "Title": page.Metadata.Title, - "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"), - "ActionDate": page.Metadata.ActionDate, - "ShortDescription": page.Metadata.ShortDescription, - "Thumbnail": page.Metadata.Thumbnail, - "Tags": page.Metadata.Tags, - }) - break - } - } - } - - slices.SortFunc(pageMeta, func(a, b fiber.Map) int { - switch sort { - case "titleAsc": - return strings.Compare(a["Title"].(string), b["Title"].(string)) - case "titleDesc": - return strings.Compare(b["Title"].(string), a["Title"].(string)) - case "actionDateAsc": - return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string)) - case "actionDateDesc": - return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string)) - case "publicationDateAsc": - publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string)) - publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string)) - return publishedTimeA.Compare(publishedTimeB) - case "publicationDateDesc": - publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string)) - publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string)) - return publishedTimeB.Compare(publishedTimeA) - } - return 0 - }) + app.Static("/", "./static") - content, err := tm.Render("catalogue-blog-cards", fiber.Map{ - "BlogPages": pageMeta, - "L": localization[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") + go func() { + if err := app.Listen(":3000"); err != nil { + slog.Error("error while running fiber server", slog.String("error", err.Error())) + panic(err) } + }() - return c.Type("html").Send(content) - }) - - app.Static("/", "./static") - - log.Fatal(app.Listen(":3000")) -} + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) -func readBlogPost(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) + <-sigChan + slog.Info("gracefully shutting down...") + if err = app.Shutdown(); err != nil { + slog.Error("fail to shutdown fiber server", slog.String("error", err.Error())) } - - 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) + if err = router.CCache.Close(); err != nil { + slog.Error("fail to dump cache", slog.String("error", err.Error())) } - - return metadata, buf.String(), nil + if err = db.Close(); err != nil { + slog.Error("fail to close db connection", slog.String("error", err.Error())) + } + 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/static/favicon.ico b/static/favicon.ico Binary files differnew file mode 100644 index 0000000..33eb226 --- /dev/null +++ b/static/favicon.ico diff --git a/static/input.css b/static/input.css index 00ac12f..47e7d16 100644 --- a/static/input.css +++ b/static/input.css @@ -1,17 +1,6 @@ @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-spectral: Spectral, serif; --font-patua: "Patua One", cursive; } @@ -109,16 +98,16 @@ a:hover { color: var(--main-light-color) !important; } -#location-map { +#map-container { color: var(--leaflet-text-color) !important; } -#location-map a { +#map-container a { color: var(--leaflet-link-color) !important; text-decoration: underline; } -#location-map a:hover { +#map-container a:hover { color: var(--leaflet-link-hover-color) !important; text-decoration: underline; } @@ -150,10 +139,18 @@ a:hover { color: var(--main-medium-color); } +.text-main-light { + color: var(--main-light-color); +} + .text-background-dark { color: var(--background-dark-color); } +.text-background-light { + color: var(--background-dark-color); +} + .text-secondary { color: var(--secondary-color); } @@ -162,6 +159,14 @@ a:hover { background-color: var(--main-dark-color); } +.bg-main-medium { + background-color: var(--main-medium-color); +} + +.bg-main-light { + background-color: var(--main-light-color); +} + .bg-background-dark { background-color: var(--background-dark-color); } @@ -178,6 +183,26 @@ a:hover { border-color: var(--main-medium-color); } +.border-main-light { + border-color: var(--main-light-color); +} + +.border-background-dark { + border-color: var(--background-dark-color); +} + +.border-background-light { + border-color: var(--background-light-color); +} + +.border-inset { + border-style: inset; +} + +.border-outset { + border-style: outset; +} + .desktop-sidebar-custom { width: 5vw; background-size: calc(var(--squares-and-triangles-background-size) * 1vw); @@ -294,6 +319,20 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { border: 0.3vmax outset var(--main-light-color); } +.custom-svg-marker { + background: transparent; + border: none; +} + +.custom-svg-marker svg { + filter: drop-shadow(2px 2px 2px rgba(0,0,0,0.3)); + transition: transform 0.2s; +} + +.custom-svg-marker:hover svg { + transform: scale(1.1); +} + .lg-backdrop { background-color: var(--background-light-color); opacity: 0.8 !important; @@ -334,14 +373,25 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { } .lg-content { - top: 3vmax !important; + top: 2.5vmax !important; + bottom: calc(10vmin + 5vmax) !important; +} + +.lg-outer[class~="lg-single-item"] .lg-content { + bottom: 5vmax !important; +} + +.lg-thumb-item { + width: 10vmin !important; + height: 10vmin !important; } .lg-container:not([class~="lg-inline"]) .lg-content { top: 4vmax !important; } -.lg-toolbar .lg-icon { +.lg-toolbar .lg-icon, +.lg-content .lg-icon { padding: 0 0.7vmax; font-size: 1.5vmax !important; height: 1.5vmax; @@ -349,11 +399,20 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { color: var(--main-dark-color); } -.lg-container:not([class~="lg-inline"]) .lg-toolbar .lg-icon { +.lg-container:not([class~="lg-inline"]) .lg-toolbar .lg-icon, +.lg-container:not([class~="lg-inline"]) .lg-content .lg-icon { font-size: 3vmax !important; height: 3vmax; } +.lg-prev { + left: 0 !important; +} + +.lg-next { + right: 0 !important; +} + .lg-toolbar .lg-icon:hover { color: var(--main-medium-color); } @@ -373,7 +432,7 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { .inline-gallery-container { width: 80%; - height: 60vmin; + height: 70vmin; } .light-gallery-captions { @@ -522,6 +581,10 @@ body[data-theme~="ram"] .leaflet-tile { width: 100vw; } + .ar-lt-0\.8\:max-h-\[25\%\] { + max-height: 25%; + } + .ar-lt-0\.8\:max-h-\[80\%\] { max-height: 80%; } @@ -553,7 +616,7 @@ body[data-theme~="ram"] .leaflet-tile { } } -.star { +.night-star { color: var(--color-yellow-100); animation-name: blink; animation-duration: 5s; @@ -562,6 +625,13 @@ body[data-theme~="ram"] .leaflet-tile { -webkit-user-select: none; } +.ram-frame { + width: 20vmin; + height: 20vmin; + user-select: none; + -webkit-user-select: none; +} + @keyframes blink { 0% { opacity: 1; diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index d2e74fa..a7cf66e 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -8,13 +8,119 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet"> - {{ block "header" . }}{{ end }} + {{ block "top-embeds" . }}{{ end }} <link href="/output.css" rel="stylesheet"> </head> <body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-0.8:flex-col"> <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'); @@ -27,6 +133,7 @@ } localStorage.setItem('theme', theme); document.body.setAttribute('data-theme', theme); + switchThemeDecor(theme); } const savedTheme = localStorage.getItem('theme') || 'ram'; @@ -44,7 +151,7 @@ <div class="text-[3vw] ar-lt-0.8:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-0.8:flex-row gap-0 relative z-20"> <i onclick="location.href='/';" class="fas fa-home w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> <i onclick="location.href='/{{ .Lang }}/blog';" class="fas fa-search w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <!-- <i class="fas fa-images w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> --> + <i onclick="location.href='/{{ .Lang }}/map';" class="fas fa-map w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> </div> <div class="theme-wheel" id="theme-wheel"> @@ -66,17 +173,19 @@ <p class="text-[2vmax] font-spectral italic text-left mt-auto">{{ .Title }}</p> <p id="published-date" class="grow text-[1.5vmax] font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p> </div> + {{ block "header" . }}{{ end }} <hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]"> {{ block "body" . }}{{ end }} <div class="flex flex-row mt-[0.8vmin] mb-[0.4vmin]"> - <p class="grow text-[0.8vmax]/[0.9] font-spectral italic text-right text-secondary mr-2"> + {{ block "footer" . }}{{ end }} + <p class="grow content-center text-[1vmax]/[1] font-spectral italic text-right text-secondary mr-2"> All the photos on <a href="https://saya.today/">saya.today</a> are licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a> by <a href="https://t.me/EarlInisMona">Saya Andy</a> © {{ .PublishedYear }} </p> - <img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> - <img src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> - <img src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> + <img class="max-w-[1vmax] max-h-[1vmax] self-center" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="CC"> + <img class="max-w-[1vmax] max-h-[1vmax] self-center" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="BY"> + <img class="max-w-[1vmax] max-h-[1vmax] self-center" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="SA"> </div> <hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]"> @@ -198,7 +307,15 @@ }); </script> - {{ block "footer" . }}{{ end }} + <script> + let decorResizeTimeout; + window.addEventListener('resize', function() { + clearTimeout(decorResizeTimeout); + decorResizeTimeout = setTimeout(switchThemeDecor, 300); + }); + </script> + + {{ block "bottom-embeds" . }}{{ end }} </body> </html> diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 2b248c3..1d0a2de 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -1,44 +1,47 @@ {{ define "body" }} <div class="flex flex-row ar-lt-0.8:flex-col max-h-[calc(100%-7vmax)] ar-lt-0.8:max-h-[calc(100%-25vh)] flex-1 text-[1vmax]"> <form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML" - class="tags-list ar-gt-0.8:min-w-[10vh] ar-gt-0.8:max-w-[20vh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] flex shrink-0 flex-col overflow-y-auto ar-gt-0.8:mr-[1.2vmin]"> - <fieldset> - <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.TagsHeader }}</legend> - <div class="flex flex-col ar-lt-0.8:grid grid-cols-5 grid-flow-row-dense gap-[5%]"> - <div> - <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> + class="tags-list ar-gt-0.8:min-w-[10vh] ar-gt-0.8:max-w-[20vh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] ar-lt-0.8:max-h-[25%] flex shrink-0 flex-col ar-gt-0.8:mr-[1.2vmin]"> + <div class="flex flex-col overflow-y-auto"> + <fieldset> + <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.TagsHeader }}</legend> + <div class="flex flex-col ar-lt-0.8:grid grid-cols-5 grid-flow-row-dense"> + <div class="m-[0.4vmin]"> + <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> + </div> + {{- range .Tags }} + <div class="m-[0.4vmin]"> + <label><input type="checkbox" id="tag{{ .Name }}Checkbox" name="tags[]" value="{{ .Name }}" onclick="contextAll();" {{ if contains $.QueryTags .Name }}checked{{ end }}> {{ .Name }} <span class="text-secondary italic">{{ .Count }}</span></label> + </div> + {{- end }} </div> - {{- range .Tags }} - <div> - <label><input type="checkbox" id="tag{{ .Name }}Checkbox" name="tags[]" value="{{ .Name }}" onclick="contextAll();" {{ if contains $.QueryTags .Name }}checked{{ end }}> {{ .Name }} <span class="text-secondary italic">{{ .Count }}</span></label> + </fieldset> + <fieldset class="mt-[0.8vmin]"> + <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> + <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col"> + <div class="m-[0.4vmin]"> + <label><input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-a-z"></i></label> + </div> + <div class="m-[0.4vmin]"> + <label><input type="radio" id="sortTitleDesc" name="sort" value="titleDesc" {{ if eq .QuerySort "titleDesc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-z-a"></i></label> + </div> + <div class="m-[0.4vmin]"> + <label><input type="radio" id="sortActionDateAsc" name="sort" value="actionDateAsc" {{ if eq .QuerySort "actionDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label> + </div> + <div class="m-[0.4vmin]"> + <label><input type="radio" id="sortActionDateDesc" name="sort" value="actionDateDesc" {{ if eq .QuerySort "actionDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label> + </div> + <div class="m-[0.4vmin]"> + <label><input type="radio" id="sortPublicationDateAsc" name="sort" value="publicationDateAsc" {{ if eq .QuerySort "publicationDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label> + </div> + <div class="m-[0.4vmin]"> + <label><input type="radio" id="sortPublicationDateDesc" name="sort" value="publicationDateDesc" {{ if eq .QuerySort "publicationDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label> + </div> </div> - {{- end }} - </div> - </fieldset> - <fieldset class="mt-[0.8vmin]"> - <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> - <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col gap-[5%]"> - <div> - <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> - <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> - <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> - <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> - <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> - <label><input type="radio" id="sortPublicationDateDesc" name="sort" value="publicationDateDesc" {{ if eq .QuerySort "publicationDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label> - </div> - </div> - </fieldset> - <div class="flex flex-row bg-background-light my-[2vmin] p-[0.4vmin] rounded-[0.4vmin] justify-items-center"> + </fieldset> + </div> + <hr class="border-t-[0.2vmax] border-dotted border-main-dark my-[0.4vmin]"> + <div class="flex flex-row grow bg-background-light my-[2vmin] p-[0.4vmin] rounded-[0.4vmin] justify-items-center"> <i onclick="defaultSearchParams(); cleanHrefParams();" class="fas fa-rotate-left flex-1 text-center text-[1.5vmax] hover:bg-background-dark cursor-pointer transition-colors duration-300"></i> <div class="w-[0.5vmin] bg-background-dark grow-0"></div> <button class="flex-1" type="submit"> @@ -56,7 +59,7 @@ </div> {{ end }} -{{ define "footer" }} +{{ define "bottom-embeds" }} <script> function selectAll() { const tagsContainers = document.querySelectorAll('form.tags-list'); diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index 330a1ac..65ee31d 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -1,4 +1,4 @@ -{{ define "header" }} +{{ define "top-embeds" }} <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"> @@ -12,6 +12,10 @@ height: 100% !important; } </style> + +<script> + const galleryMap = new Map(); +</script> {{ end }} {{ define "body" }} @@ -19,13 +23,17 @@ {{ .ParsedMarkdown }} {{- if .MapLocationX }} <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%] ml-auto mr-auto"> - <div id="location-map" class="relative p-[0.8vmin] flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40vh] ar-lt-0.8:h-[30vh]"></div> + <div id="map-container" class="relative p-[0.8vmin] flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40vh] ar-lt-0.8:h-[30vh]"></div> <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%] ml-auto mr-auto"> {{- end }} </div> {{ end }} {{ define "footer" }} +<div hx-get="/api/v1/like" hx-target="this" hx-swap="outerHTML" hx-trigger="load"></div> +{{ end }} + +{{ define "bottom-embeds" }} <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> @@ -33,8 +41,10 @@ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script> <script> + var map; + function initLocationMap(id, x, y, relativeErrorMeters = 0) { - var map = L.map(id).setView([x, y], 8); + map = L.map(id).setView([x, y], 8); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, @@ -58,16 +68,24 @@ } document.addEventListener('DOMContentLoaded', () => {initLocationMap( - 'location-map', + 'map-container', {{ .MapLocationX }}, {{ .MapLocationY }}, {{ .MapLocationAreaMeters }} )}); + let galleryResizeTimeout; window.addEventListener('resize', function() { - setTimeout(() => { - if (window.location-map) window.location-map.invalidateSize(); - }, 100); + clearTimeout(galleryResizeTimeout); + galleryResizeTimeout = setTimeout(() => { + map.invalidateSize(); + var oldGalleryMap = new Map(galleryMap); + oldGalleryMap.forEach((createFunc, g, map) => { + galleryMap.delete(g); + g.destroy(); + createFunc(); + }); + }, 300); }); </script> {{- end }} diff --git a/views/pages/global-map.html b/views/pages/global-map.html new file mode 100644 index 0000000..569166f --- /dev/null +++ b/views/pages/global-map.html @@ -0,0 +1,267 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>SAYA TODAY // {{ .L.GlobalMap.Header }}</title> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet"> + {{ block "header" . }}{{ end }} + <link href="/output.css" rel="stylesheet"> + <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css"> + <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-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-0.8:flex-col"> + + <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 = "olive"; break; + } + } + localStorage.setItem('theme', theme); + document.body.setAttribute('data-theme', theme); + } + + const savedTheme = localStorage.getItem('theme') || 'ram'; + switchTheme(savedTheme); + </script> + + <script> + const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + </script> + + <div id="sidebar" class="bg-sidebar flex sticky flex-col ar-gt-0.8:items-center shadow-2xl z-20 w-[5vw] ar-lt-0.8:w-screen h-screen ar-lt-0.8:h-[8vh]"> + <div class="logo-custom ar-lt-0.8:hidden text-sidebar font-patua font-extrabold text-center relative z-20"> + saya.today + </div> + <div class="text-[3vw] ar-lt-0.8:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-0.8:flex-row gap-0 relative z-20"> + <i onclick="location.href='/';" class="fas fa-home w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> + <i onclick="location.href='/{{ .Lang }}/blog';" class="fas fa-search w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> + <i onclick="location.href='/{{ .Lang }}/map';" class="fas fa-map w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> + <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> + </div> + <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> + </div> + + <div id="map-container" class="bg-background-dark flex z-10 w-[95vw] ar-lt-0.8:w-screen h-screen ar-lt-0.8:h-[92vh]"></div> + + <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'); + + 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 icons = themeWheel.querySelectorAll('.theme-icon'); + const iconCount = icons.length; + const vmax = calculateVmax(1); + const wheelRadius = 7.5*vmax; // Radius of the wheel + const iconRadius = 4*vmax; // Distance from center to place icons + const iconSize = 3*vmax; // Half of icon size for centering + + icons.forEach((icon, index) => { + const angle = (index * 360 / iconCount) - 90; + const angleRad = (angle * Math.PI) / 180; + + const x = Math.cos(angleRad) * iconRadius + wheelRadius - iconSize; + const y = Math.sin(angleRad) * iconRadius + wheelRadius - iconSize; + + icon.style.left = x + 'px'; + icon.style.top = y + 'px'; + }); + } + + function positionThemeWheel() { + const buttonRect = themeButton.getBoundingClientRect(); + const buttonCenterX = buttonRect.left + buttonRect.width / 2; + const buttonCenterY = buttonRect.top + buttonRect.height / 2; + + const wheelRadius = calculateVmax(7.5); + const margin = calculateVmax(1); + + let wheelX = buttonCenterX - wheelRadius; + let wheelY = buttonCenterY - wheelRadius; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (wheelX < margin) { + wheelX = margin; + } else if (wheelX + (wheelRadius * 2) > viewportWidth - margin) { + wheelX = viewportWidth - (wheelRadius * 2) - margin; + } + + if (wheelY < margin) { + wheelY = margin; + } else if (wheelY + (wheelRadius * 2) > viewportHeight - margin) { + wheelY = viewportHeight - (wheelRadius * 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-light-color)") { + const svgIcon = ` + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 1C7.5 1 4 4.5 4 9c0 6.5 8 14 8 14s8-7.5 8-14c0-4.5-3.5-8-8-8zm0 11c-1.5 0-3-1.5-3-3s1.5-3 3-3 3 1.5 3 3-1.5 3-3 3z" + fill="${color}" + stroke="${borderColor}" + stroke-width="1" + stroke-linejoin="round"/> + </svg> + `; + + return L.divIcon({ + html: svgIcon, + className: 'custom-svg-marker', + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -32] + }); + } + + function initLocationMap() { + map = L.map('map-container').setView([{{ .MapLocationLat }}, {{ .MapLocationLong }}], 4); + + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' + }).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-[3vmax] h-[3vmax] mr-[0.5vmax]" 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/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html new file mode 100644 index 0000000..afc3027 --- /dev/null +++ b/views/partials/blog-page-like-button.html @@ -0,0 +1,7 @@ +<button + hx-put="/api/v1/like" hx-vals='{"like": {{ not .Liked }}}' hx-target="this" hx-swap="outerHTML" hx-trigger="click" + class="text-[0.8vmax]/[0.9] font-spectral text-left px-[0.4vmax] py-[0.2vmax] {{ if .Liked }}bg-main-light hover:bg-main-medium text-background-dark border-inset{{ else }}bg-main-dark hover:bg-main-medium text-background-light border-outset{{ end }} cursor-pointer border-[0.2vmax] border-background-dark"> + <i class="fas fa-thumbs-up w-[0.8vmax] h-[0.8vmax] mr-[0.4vmax]"></i> + <span>{{ .L.LikeButton }}</span> + <span class="italic ml-[0.2vmax] text-background-light">({{ .LikedCount }})</span> +</button>
\ No newline at end of file diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html index 406ef9b..35c7ad7 100644 --- a/views/partials/catalogue-blog-cards.html +++ b/views/partials/catalogue-blog-cards.html @@ -1,13 +1,16 @@ {{- range .BlogPages }} <hr class="first-of-type:hidden border-t-[0.3vh] my-[0.8vmin] border-dotted border-main-dark w-[80%] mx-auto"> <div class="m-[0.4vmin] flex flex-row justify-center justify-items-center"> - <img onclick="location.href='{{ .ArticleLink }}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[10vmin] h-[10vmin] aspect-square my-[0.4vmin] mr-[0.8vmin]" src="https://f003.backblazeb2.com/file/sayana-photos/thumbnails/{{ .Thumbnail }}.webp"> + <img onclick="location.href='{{ .ArticleLink }}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[10vmin] h-[10vmin] aspect-square my-[0.4vmin] mr-[0.8vmin]" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp"> <div class="grow my-[0.4vmin] mx-[0.8vmin] flex flex-col justify-center"> <div class="flex flex-row gap-[5%] max-w-[100%]"> <a href="{{ .ArticleLink }}" class="flex-9/12 grow-0 block text-[1vmax] cursor-pointer"> <span class="font-extrabold">{{ .Title }}</span> <span class="font-extrabold select-none text-secondary">//</span> <span class="italic text-secondary">{{ .ActionDate }}</span> + <span class="font-extrabold select-none text-secondary">//</span> + <i class="fas fa-thumbs-up w-[1vmax] h-[1vmax]"></i> + <span class="italic text-secondary">{{ .LikeCount }}</span> </a> <p class="flex-3/12 grow-0 text-[1vmax] italic text-secondary text-right">{{ .PublishedTime }}</p> </div> |