summaryrefslogtreecommitdiff
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
-rw-r--r--Dockerfile9
-rw-r--r--config/config.go18
-rw-r--r--config/config.local.yaml14
-rw-r--r--config/config.prod.yaml12
-rw-r--r--config/config.stage.yaml12
-rw-r--r--go.mod42
-rw-r--r--go.sum78
-rw-r--r--internal/factgiver/factgiver.go67
-rw-r--r--internal/frontmatter/parser.go2
-rw-r--r--internal/lightgallery/html_renderer.go12
-rw-r--r--internal/router/api-v1-blog-search.go19
-rw-r--r--internal/router/api-v1-general-page-body.go162
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go75
-rw-r--r--internal/router/api-v1-general-page-footer.go75
-rw-r--r--internal/router/api-v1-general-page-header.go85
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go75
-rw-r--r--internal/router/api-v1-general-page.go52
-rw-r--r--internal/router/client-cache.go249
-rw-r--r--internal/router/lang-blog-title.go1
-rw-r--r--internal/router/lang-blog.go3
-rw-r--r--internal/router/page-cache.go5
-rw-r--r--internal/templatemanager/templatemanager.go38
-rw-r--r--locale/localization.en.yaml14
-rw-r--r--locale/localization.go17
-rw-r--r--locale/localization.ru.yaml14
-rw-r--r--main.go107
-rw-r--r--migrations/1_create_stats_tables.down.sql2
-rw-r--r--migrations/1_create_stats_tables.up.sql11
-rw-r--r--static/favicon.icobin0 -> 5694 bytes
-rw-r--r--static/input.css91
-rw-r--r--views/index.html2
-rw-r--r--views/layouts/general-page.html79
-rw-r--r--views/pages/blog-catalogue.html116
-rw-r--r--views/pages/blog-page.html122
-rw-r--r--views/pages/global-map.html18
-rw-r--r--views/pages/home-page.html121
-rw-r--r--views/partials/blog-page-like-button.html3
-rw-r--r--views/partials/catalogue-blog-cards.html11
-rw-r--r--views/partials/general-page-body.html2
-rw-r--r--views/partials/general-page-bottom-embeds.html1
-rw-r--r--views/partials/general-page-footer.html7
-rw-r--r--views/partials/general-page-header.html8
-rw-r--r--views/partials/general-page-top-embeds.html1
43 files changed, 1555 insertions, 297 deletions
diff --git a/Dockerfile b/Dockerfile
index ffe5ac3..eeee6c5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,10 @@
-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
@@ -13,11 +15,12 @@ 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 fe0ac72..f57b5cd 100644
--- a/config/config.go
+++ b/config/config.go
@@ -11,6 +11,7 @@ import (
type Config struct {
LogLevel slog.Level `json:"LogLevel" yaml:"logLevel" validate:"required"`
BlogPages BlogPagesConfig `json:"BlogPages" yaml:"blogPages" validate:"required"`
+ FactGiver FactGiverConfig `json:"FactGiver" yaml:"factGiver" validate:"required"`
LocalePath string `json:"LocalePath" yaml:"localePath" validate:"required,filepath"`
AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"`
Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"`
@@ -33,6 +34,11 @@ type B2Config struct {
ApplicationKey string `json:"ApplicationKey" yaml:"applicationKey"`
}
+type FactGiverConfig struct {
+ Storage StorageConfig `json:"Storage" yaml:"storage" validate:"required"`
+ FactsFileName string `json:"FactsFileName" yaml:"factsFileName" validate:"required"`
+}
+
type AvailableLanguageConfig struct {
Name string `json:"Name" yaml:"name" validate:"required"`
Alt string `json:"Alt" yaml:"alt"`
@@ -41,7 +47,17 @@ type AvailableLanguageConfig struct {
}
type AuthConfig struct {
- Salt string `json:"Salt" yaml:"salt" validate:"required"`
+ 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 {
diff --git a/config/config.local.yaml b/config/config.local.yaml
index 330e29c..6d0abf3 100644
--- a/config/config.local.yaml
+++ b/config/config.local.yaml
@@ -8,6 +8,16 @@ blogPages:
prefix: 'stage-'
keyID: '${B2_KEY_ID}'
applicationKey: '${B2_APPLICATION_KEY}'
+factGiver:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: ''
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+ factsFileName: 'facts-*.txt'
localePath: ./locale/
availableLanguages:
- name: ru
@@ -20,7 +30,7 @@ availableLanguages:
locFile: localization.en.yaml
auth:
db:
- type: sqlite
+ type: sqlite3
config:
- dsn: 'file:/data/auth.db?cache=shared&mode=memory'
+ dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc'
salt: '123'
diff --git a/config/config.prod.yaml b/config/config.prod.yaml
index 76288fa..38981a8 100644
--- a/config/config.prod.yaml
+++ b/config/config.prod.yaml
@@ -8,6 +8,16 @@ blogPages:
prefix: 'prod-'
keyID: '${B2_KEY_ID}'
applicationKey: '${B2_APPLICATION_KEY}'
+factGiver:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: ''
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+ factsFileName: 'facts-*.txt'
localePath: ./locale/
availableLanguages:
- name: ru
@@ -20,7 +30,7 @@ availableLanguages:
locFile: localization.en.yaml
auth:
db:
- type: sqlite
+ 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 f6c16cb..b05edaf 100644
--- a/config/config.stage.yaml
+++ b/config/config.stage.yaml
@@ -8,6 +8,16 @@ blogPages:
prefix: 'stage-'
keyID: '${B2_KEY_ID}'
applicationKey: '${B2_APPLICATION_KEY}'
+factGiver:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: ''
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+ factsFileName: 'facts-*.txt'
localePath: ./locale/
availableLanguages:
- name: ru
@@ -20,7 +30,7 @@ availableLanguages:
locFile: localization.en.yaml
auth:
db:
- type: sqlite
+ 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/go.mod b/go.mod
index e2e916e..e7547b1 100644
--- a/go.mod
+++ b/go.mod
@@ -1,33 +1,39 @@
module github.com/SayaAndy/saya-today-web
-go 1.24.5
+go 1.24.6
-require github.com/gofiber/fiber/v2 v2.52.9
+require (
+ github.com/Backblaze/blazer v0.7.2
+ github.com/go-playground/validator/v10 v10.27.0
+ github.com/gofiber/fiber/v2 v2.52.9
+ github.com/golang-migrate/migrate/v4 v4.18.3
+ github.com/mattn/go-sqlite3 v1.14.32
+ github.com/yuin/goldmark v1.7.13
+ golang.org/x/crypto v0.41.0
+ gopkg.in/yaml.v3 v3.0.1
+)
require (
- github.com/Backblaze/blazer v0.7.2 // indirect
- github.com/andybalholm/brotli v1.1.0 // indirect
- github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-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.41.0 // indirect
- golang.org/x/net v0.42.0 // 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
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index b3ae996..bbfad9e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,20 @@
github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQM=
github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10=
-github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
-github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
-github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
-github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
+github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
+github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/go-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,55 +23,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=
+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/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
-golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
-golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
-golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-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/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/factgiver/factgiver.go b/internal/factgiver/factgiver.go
new file mode 100644
index 0000000..6f77403
--- /dev/null
+++ b/internal/factgiver/factgiver.go
@@ -0,0 +1,67 @@
+package factgiver
+
+import (
+ "fmt"
+ "math/rand"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+)
+
+type FactGiver struct {
+ b2Client *b2.B2Client
+ cache map[string][]string
+ langs []string
+ factsFileName string
+ nlRe *regexp.Regexp
+ randGen *rand.Rand
+}
+
+func NewFactGiver(cfg *config.FactGiverConfig, langs []string) (*FactGiver, error) {
+ b2Client, err := b2.NewB2Client(&cfg.Storage.Config)
+ if err != nil {
+ return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error())
+ }
+
+ factGiver := &FactGiver{
+ b2Client: b2Client,
+ cache: make(map[string][]string, len(langs)),
+ langs: langs,
+ factsFileName: cfg.FactsFileName,
+ nlRe: regexp.MustCompile(`\r?\n`),
+ randGen: rand.New(rand.NewSource(time.Now().UnixNano())),
+ }
+ if err = factGiver.initCache(); err != nil {
+ return nil, fmt.Errorf("fail to init cache for a new fact giver: %s", err.Error())
+ }
+
+ return factGiver, nil
+}
+
+func (g *FactGiver) Give(lang string) [3]string {
+ factSlice := make([]string, len(g.cache[lang]))
+ copy(factSlice, g.cache[lang])
+ g.randGen.Shuffle(len(factSlice), func(i, j int) {
+ factSlice[i], factSlice[j] = factSlice[j], factSlice[i]
+ })
+ return [3]string{factSlice[0], factSlice[1], factSlice[2]}
+}
+
+func (g *FactGiver) initCache() error {
+ for _, lang := range g.langs {
+ localFacts := strings.Replace(g.factsFileName, "*", lang, 1)
+ factsContentBytes, err := g.b2Client.ReadAll(localFacts)
+ if err != nil {
+ return fmt.Errorf("fail to read '%s' facts file: %s", lang, err.Error())
+ }
+ factsContent := string(factsContentBytes)
+ g.cache[lang] = g.nlRe.Split(factsContent, -1)
+ if g.cache[lang][len(g.cache[lang])-1] == "" {
+ g.cache[lang] = g.cache[lang][:len(g.cache[lang])-1]
+ }
+ }
+ return nil
+}
diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go
index 1a521c0..83312dc 100644
--- a/internal/frontmatter/parser.go
+++ b/internal/frontmatter/parser.go
@@ -16,6 +16,8 @@ type Metadata struct {
Thumbnail string `yaml:"thumbnail"`
Tags []string `yaml:"tags"`
Geolocation string `yaml:"geolocation"`
+ Medley string `yaml:"medley"`
+ MedleyPart int `yaml:"medleyPart"`
}
func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) {
diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go
index 17a6f2b..8e184e0 100644
--- a/internal/lightgallery/html_renderer.go
+++ b/internal/lightgallery/html_renderer.go
@@ -60,7 +60,8 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [
for _, img := range gallery.Images {
imageUrlSegments := strings.Split(img.URL, ".")
imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".")
- imageNameParts := strings.Split(imageUrlWithoutExt, "-")
+ imageUrlParts := strings.Split(imageUrlWithoutExt, "/")
+ imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-")
var captionBuf bytes.Buffer
captionHTML := img.Caption
@@ -119,7 +120,14 @@ function createLightGallery%s() {
galleryMap.set(inlineGallery, createLightGallery%s);
}
-document.addEventListener('DOMContentLoaded', createLightGallery%s);
+document.addEventListener('htmx:afterRequest', (e) => {
+ if (e.detail.xhr.status == 404 || e.detail.successful != true) {
+ return console.error(e);
+ }
+ if (e.detail.target.id == 'general-page-body') {
+ createLightGallery%s();
+ }
+}, {once: true});
</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID))
}
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
index a809783..69be651 100644
--- a/internal/router/api-v1-blog-search.go
+++ b/internal/router/api-v1-blog-search.go
@@ -1,6 +1,7 @@
package router
import (
+ "encoding/json"
"fmt"
"log/slog"
"net/url"
@@ -35,10 +36,18 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie
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))
+ cacheKey := "blog-search." + lang + ".pages-list"
+ var pages []*b2.BlogPage
+ if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ pages, err = b2Client.Scan(lang + "/")
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err))
+ }
+ pagesBytes, _ := json.Marshal(pages)
+ PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute)
}
encodedQuery := c.Request().URI().QueryString()
@@ -69,6 +78,8 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie
"ShortDescription": page.Metadata.ShortDescription,
"Thumbnail": page.Metadata.Thumbnail,
"Tags": page.Metadata.Tags,
+ "LikeCount": CCache.GetLikeCount(page.FileName),
+ "ViewCount": CCache.GetViewCount(page.FileName),
})
break
}
diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go
new file mode 100644
index 0000000..b2fa268
--- /dev/null
+++ b/internal/router/api-v1-general-page-body.go
@@ -0,0 +1,162 @@
+package router
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "math/rand"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+ "github.com/yuin/goldmark"
+)
+
+var FactGiver *factgiver.FactGiver
+
+func init() {
+ tm.Add("general-page-body", "views/partials/general-page-body.html")
+}
+
+func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+
+ cacheKey := fmt.Sprintf("body.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ querySort := c.Query("sort")
+ if querySort == "" {
+ querySort = "publicationDateDesc"
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ re, err := regexp.Compile(`tags\[\]=([\w]+)`)
+ if err != nil {
+ slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
+ }
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := re.FindAllStringSubmatch(decodedQuery, -1)
+
+ queryTags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ queryTags = append(queryTags, string(match[1]))
+ }
+
+ pages, err := b2Client.Scan(lang + "/")
+ if err != nil {
+ slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages via b2: %s", slog.String("error", err.Error())))
+ }
+
+ 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)
+ })
+
+ values["Tags"] = tagsArray
+ values["QuerySort"] = querySort
+ values["QueryTags"] = strings.Join(queryTags, ",")
+ values["Title"] = l[lang].BlogSearch.Header
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2]))
+ }
+
+ geolocationParts := strings.Split(metadata.Geolocation, " ")
+ var x, y, areaError string
+ if len(geolocationParts) >= 2 {
+ x = geolocationParts[0]
+ y = geolocationParts[1]
+ }
+ if len(geolocationParts) >= 3 {
+ areaError = geolocationParts[2]
+ }
+
+ values["MapLocationX"] = x
+ values["MapLocationY"] = y
+ values["MapLocationAreaMeters"] = areaError
+ values["Title"] = metadata.Title
+ values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+
+ go CCache.View(c.IP(), pathParts[2])
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ values["FilledHeartCount"] = uint(40)
+ values["OutlineHeartCount"] = uint(40)
+ values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
+ values["FunFacts"] = FactGiver.Give(lang)
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-body", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go
new file mode 100644
index 0000000..c4d9543
--- /dev/null
+++ b/internal/router/api-v1-general-page-bottom-embeds.go
@@ -0,0 +1,75 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "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("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html")
+}
+
+func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ cacheKey := fmt.Sprintf("bottom-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
new file mode 100644
index 0000000..003f54c
--- /dev/null
+++ b/internal/router/api-v1-general-page-footer.go
@@ -0,0 +1,75 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("general-page-footer", "views/partials/general-page-footer.html")
+}
+
+func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+
+ cacheKey := fmt.Sprintf("footer.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-footer", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
new file mode 100644
index 0000000..80228f7
--- /dev/null
+++ b/internal/router/api-v1-general-page-header.go
@@ -0,0 +1,85 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "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("general-page-header", "views/partials/general-page-header.html")
+}
+
+func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+
+ cacheKey := fmt.Sprintf("header.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ values["Title"] = l[lang].BlogSearch.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path))
+ }
+ values["Title"] = metadata.Title
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-header", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
new file mode 100644
index 0000000..78021b2
--- /dev/null
+++ b/internal/router/api-v1-general-page-top-embeds.go
@@ -0,0 +1,75 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html")
+}
+
+func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+
+ cacheKey := fmt.Sprintf("top-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
new file mode 100644
index 0000000..d0ea559
--- /dev/null
+++ b/internal/router/api-v1-general-page.go
@@ -0,0 +1,52 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("general-page", "views/layouts/general-page.html")
+}
+
+func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ path := c.Path()
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("url path is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ cacheKey := "general-page." + lang
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ content, err := tm.Render("general-page", fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.Set(cacheKey, content, int64(len(content)))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
index 7d276b1..d607cdd 100644
--- a/internal/router/client-cache.go
+++ b/internal/router/client-cache.go
@@ -1,8 +1,11 @@
package router
import (
+ "database/sql"
"encoding/base64"
+ "fmt"
"log/slog"
+ "strings"
"sync"
"golang.org/x/crypto/argon2"
@@ -10,35 +13,117 @@ import (
type ClientCache struct {
hashMap map[string]string
- mutexLikeMap map[string]*sync.Mutex
- mutexHashMap map[string]*sync.Mutex
- likePageMap map[string]map[string]struct{}
- salt []byte
+ hashMapMutex sync.RWMutex
+
+ likePageMap map[string]map[string]struct{}
+ viewPageMap map[string]map[string]struct{}
+ pageMutexMap map[string]*sync.RWMutex
+ pageMutexMapMutex sync.Mutex
+
+ salt []byte
+ db *sql.DB
}
var CCache *ClientCache
-func NewClientCache(salt []byte) *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{})
+ viewPageMap := make(map[string]map[string]struct{})
+ pageMutexMap := make(map[string]*sync.RWMutex)
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := likePageMap[pageRef]; !ok {
+ likePageMap[pageRef] = make(map[string]struct{})
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ likePageMap[pageRef][userIdString] = struct{}{}
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ rows, err = tx.Query("select * from blog_views;")
+ if err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err)
+ }
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_views to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := viewPageMap[pageRef]; !ok {
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ if err = tx.Commit(); err != nil {
+ return nil, fmt.Errorf("fail to commit transaction in db: %w", err)
+ }
+
return &ClientCache{
hashMap: make(map[string]string),
- mutexLikeMap: make(map[string]*sync.Mutex),
- mutexHashMap: make(map[string]*sync.Mutex),
- likePageMap: make(map[string]map[string]struct{}),
+ likePageMap: likePageMap,
+ viewPageMap: viewPageMap,
+ pageMutexMap: pageMutexMap,
salt: salt,
+ db: db,
+ }, nil
+}
+
+func (c *ClientCache) Close() error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return fmt.Errorf("fail to init transaction with db to dump cache: %w", err)
+ }
+
+ if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("fail to save blog_likes: %s", err)
}
+
+ if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("fail to save blog_views: %s", err)
+ }
+
+ 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()
- if _, ok := c.mutexHashMap[id]; !ok {
- c.mutexHashMap[id] = &sync.Mutex{}
- }
- c.mutexHashMap[id].Lock()
- defer c.mutexHashMap[id].Unlock()
+ 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))
@@ -50,7 +135,25 @@ func (c *ClientCache) GetHash(id string) string {
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
}
@@ -59,6 +162,12 @@ func (c *ClientCache) GetLikeStatus(id string, page string) bool {
}
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)
}
@@ -66,14 +175,13 @@ func (c *ClientCache) GetLikeCount(page string) int {
}
func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) {
- if _, ok := c.mutexLikeMap[id]; !ok {
- c.mutexLikeMap[id] = &sync.Mutex{}
- }
- c.mutexLikeMap[id].Lock()
- defer c.mutexLikeMap[id].Unlock()
-
+ 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{}{}
@@ -86,16 +194,16 @@ func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) {
}
func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
- if _, ok := c.mutexLikeMap[id]; !ok {
- c.mutexLikeMap[id] = &sync.Mutex{}
- }
- c.mutexLikeMap[id].Lock()
- defer c.mutexLikeMap[id].Unlock()
+ 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
}
- hash := c.GetHash(id)
if _, ok := c.likePageMap[page][hash]; !ok {
return true
}
@@ -103,3 +211,94 @@ func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
delete(c.likePageMap[page], hash)
return false
}
+
+func (c *ClientCache) GetViewStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.viewPageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetViewCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.viewPageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) View(id string, page string) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ c.viewPageMap[page] = make(map[string]struct{})
+ }
+ c.viewPageMap[page][hash] = struct{}{}
+}
+
+func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) {
+ if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil {
+ return fmt.Errorf("fail to truncate table %s: %w", table, err)
+ }
+
+ userIdBytes := make(map[string][]byte)
+
+ sqlStatement := fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", 99))
+ sqlStatementVars := make([]any, 0, 200)
+
+ for pageRef, userSet := range pageMap {
+ for userId := range userSet {
+ if _, ok := userIdBytes[userId]; !ok {
+ userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId)
+ if err != nil {
+ slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error()))
+ continue
+ }
+ }
+
+ sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId]))
+ if len(sqlStatementVars) < 200 {
+ continue
+ }
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+
+ sqlStatementVars = make([]any, 0, 200)
+ }
+ }
+
+ if len(sqlStatementVars) > 0 {
+ sqlStatement = fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+ }
+
+ return nil
+}
diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go
index bafb0d2..d6ca917 100644
--- a/internal/router/lang-blog-title.go
+++ b/internal/router/lang-blog-title.go
@@ -56,6 +56,7 @@ func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client
"MapLocationY": y,
"MapLocationAreaMeters": areaError,
"Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
})
if err != nil {
slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error()))
diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go
index e5220c6..38a6597 100644
--- a/internal/router/lang-blog.go
+++ b/internal/router/lang-blog.go
@@ -76,6 +76,7 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B
content, err := tm.Render("blog-catalogue", fiber.Map{
"QuerySort": querySort,
"QueryTags": strings.Join(queryTags, ","),
+ "QueryString": string(c.Request().URI().QueryString()),
"Tags": tagsArray,
"Lang": lang,
"L": l[lang],
@@ -83,7 +84,7 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B
"Title": l[lang].BlogSearch.Header,
})
if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error()))
+ 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")
}
diff --git a/internal/router/page-cache.go b/internal/router/page-cache.go
new file mode 100644
index 0000000..01e4792
--- /dev/null
+++ b/internal/router/page-cache.go
@@ -0,0 +1,5 @@
+package router
+
+import "github.com/dgraph-io/ristretto/v2"
+
+var PCache *ristretto.Cache[string, []byte]
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
index f89d0c8..4bf9a49 100644
--- a/internal/templatemanager/templatemanager.go
+++ b/internal/templatemanager/templatemanager.go
@@ -22,13 +22,22 @@ type TemplateManagerTemplates struct {
Files []string
}
+var templateFuncMap = template.FuncMap{
+ "contains": strings.Contains,
+ "iterate": func(count uint) []uint {
+ items := make([]uint, count)
+ for i := range count {
+ items[i] = i
+ }
+ return items
+ },
+}
+
func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) {
templateMap := make(map[string]templateManagerRender)
for _, tmplStruct := range templates {
- tmpl := template.New("").Funcs(template.FuncMap{
- "contains": strings.Contains,
- })
+ tmpl := template.New("").Funcs(templateFuncMap)
tmpl, err := tmpl.ParseFiles(tmplStruct.Files...)
if err != nil {
return nil, err
@@ -44,14 +53,29 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager
}, nil
}
-func (tm *TemplateManager) Render(name string, data interface{}) ([]byte, error) {
+func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) {
tmpl, exists := tm.templates[name]
if !exists {
return nil, fmt.Errorf("template %s is not found", name)
}
+ var err error
+ var tempTmpl *template.Template
+ if len(files) == 0 {
+ tempTmpl = tmpl.Tmpl
+ } else {
+ tempTmpl, err = tmpl.Tmpl.Clone()
+ if err != nil {
+ return nil, fmt.Errorf("couldn't clone existing template for rendering: %w", err)
+ }
+ tempTmpl, err = tempTmpl.ParseFiles(files...)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't include additional files in template rendering: %w", err)
+ }
+ }
+
var buf bytes.Buffer
- err := tmpl.Tmpl.ExecuteTemplate(&buf, tmpl.Main, data)
+ err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data)
return buf.Bytes(), err
}
@@ -60,9 +84,7 @@ func (tm *TemplateManager) Add(name string, files ...string) error {
return fmt.Errorf("you can't add template without any files")
}
- tmpl := template.New("").Funcs(template.FuncMap{
- "contains": strings.Contains,
- })
+ tmpl := template.New("").Funcs(templateFuncMap)
tmpl, err := tmpl.ParseFiles(files...)
if err != nil {
diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml
index 48ef9da..1b3654b 100644
--- a/locale/localization.en.yaml
+++ b/locale/localization.en.yaml
@@ -9,4 +9,16 @@ BlogSearch:
ChooseAllTags: 'Choose All'
GlobalMap:
Header: 'Global Map'
-LikeButton: 'Like!'
+HomePage:
+ Header: 'Home Page'
+ Contacts: 'Contacts'
+ DidYouKnowThat: 'Did you know, that...'
+ SidebarDescription: 'Describing the Sidebar'
+ HomePageDescription: 'Home Page -- where we are right now!'
+ BlogSearchDescription: 'Blog Search. You can find my travel notes there and, maybe, something else!'
+ MarkerMapDescription: 'Map with markers of my travel notes -- a convinient way to find an interesting region and find related posts!'
+ ThemeSwitchDescription: 'Theme Palette lets you change the theme of the site -- there are 2 dark and 2 bright themes currently!'
+ Hymn1: 'We welcome you to Sayasite!'
+ Hymn2: "We've been expecting you!"
+ Hymn3: 'You bring such joy in Sayasite!'
+ Hymn4: 'No matter where you roam, know our love is true!~'
diff --git a/locale/localization.go b/locale/localization.go
index b557e08..088783f 100644
--- a/locale/localization.go
+++ b/locale/localization.go
@@ -11,7 +11,7 @@ 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"`
+ HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"`
}
type BlogSearchConfig struct {
@@ -28,6 +28,21 @@ type GlobalMapConfig struct {
Header string `yaml:"Header" json:"Header"`
}
+type HomePageConfig struct {
+ Header string `yaml:"Header" json:"Header"`
+ Contacts string `yaml:"Contacts" json:"Contacts"`
+ DidYouKnowThat string `yaml:"DidYouKnowThat" json:"DidYouKnowThat"`
+ SidebarDescription string `yaml:"SidebarDescription" json:"SidebarDescription"`
+ HomePageDescription string `yaml:"HomePageDescription" json:"HomePageDescription"`
+ BlogSearchDescription string `yaml:"BlogSearchDescription" json:"BlogSearchDescription"`
+ MarkerMapDescription string `yaml:"MarkerMapDescription" json:"MarkerMapDescription"`
+ ThemeSwitchDescription string `yaml:"ThemeSwitchDescription" json:"ThemeSwitchDescription"`
+ Hymn1 string `yaml:"Hymn1" json:"Hymn1"`
+ Hymn2 string `yaml:"Hymn2" json:"Hymn2"`
+ Hymn3 string `yaml:"Hymn3" json:"Hymn3"`
+ Hymn4 string `yaml:"Hymn4" json:"Hymn4"`
+}
+
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 d6e9fe4..9d7084f 100644
--- a/locale/localization.ru.yaml
+++ b/locale/localization.ru.yaml
@@ -9,4 +9,16 @@ BlogSearch:
ChooseAllTags: 'Выбрать все'
GlobalMap:
Header: 'Глобальная карта'
-LikeButton: 'Нраица!'
+HomePage:
+ Header: 'Домашняя страница'
+ Contacts: 'Контакты'
+ DidYouKnowThat: 'А вы знали, что...'
+ SidebarDescription: 'Поясняем за сайдбар'
+ HomePageDescription: 'Домашняя страница -- где мы сейчас и находимся!'
+ BlogSearchDescription: 'Поиск по блогу. Там размещены мои путевые заметки и, может, что-то ещё!'
+ MarkerMapDescription: 'Карта с маркерами моих путевых заметок, -- удобный способ найти интересный регион и перейти на заинтересовавшие посты!'
+ ThemeSwitchDescription: 'Позволяет поменять тему сайта -- есть 2 светлые и 2 тёмные темы!'
+ Hymn1: 'Приветствуем Вас на Саясайте!'
+ Hymn2: 'Мы Вас столько ждали!'
+ Hymn3: 'Столько радости от Вас на Саясайте!'
+ Hymn4: 'Где бы Вы не были, знайте, -- наша любовь крепка!'
diff --git a/main.go b/main.go
index 36463ff..20e90f4 100644
--- a/main.go
+++ b/main.go
@@ -1,22 +1,32 @@
package main
import (
+ "database/sql"
+ "errors"
"flag"
- "log"
"log/slog"
"os"
+ "os/signal"
+ "syscall"
"github.com/SayaAndy/saya-today-web/config"
"github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
"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/locale"
+ "github.com/dgraph-io/ristretto/v2"
"github.com/gofiber/fiber/v2"
- "github.com/gofiber/fiber/v2/middleware/redirect"
+ "github.com/gofiber/fiber/v2/middleware/cors"
+ "github.com/golang-migrate/migrate/v4"
+ "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
gmhtml "github.com/yuin/goldmark/renderer/html"
+
+ _ "github.com/golang-migrate/migrate/v4/source/file"
+ _ "github.com/mattn/go-sqlite3"
)
var (
@@ -52,6 +62,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()))
@@ -72,26 +108,73 @@ func main() {
ProxyHeader: "X-Forwarded-For",
})
- app.Use(redirect.New(redirect.Config{
- Rules: map[string]string{
- "/ru": "/",
- "/en": "/",
- },
- StatusCode: 301,
+ app.Use(cors.New(cors.Config{
+ AllowOrigins: "https://f003.backblazeb2.com",
+ AllowMethods: "GET,POST,OPTIONS",
+ AllowHeaders: "Origin, Content-Type, Accept",
}))
- router.CCache = router.NewClientCache([]byte(cfg.Auth.Salt))
+ router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt))
+ if err != nil {
+ slog.Error("fail to initialize client cache", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ router.PCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
+ NumCounters: 1e6, // 1,000,000
+ MaxCost: 1 << 29, // 512 MB
+ BufferItems: 64, // number of keys per Get buffer.
+ })
+ if err != nil {
+ slog.Error("fail to initialize page cache", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ router.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, availableLanguages)
+ if err != nil {
+ slog.Error("fail to initialize fact giver", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
app.Get("/", router.Root(cfg.AvailableLanguages))
+ app.Get("/:lang<len(2)>", router.Api_V1_GeneralPage(localization, availableLanguages))
app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client))
- app.Get("/:lang/blog", router.Lang_Blog(localization, availableLanguages, b2Client))
- app.Get("/:lang/blog/:title", router.Lang_Blog_Title(localization, availableLanguages, b2Client, md))
+ app.Get("/:lang/blog", router.Api_V1_GeneralPage(localization, availableLanguages))
+ app.Get("/:lang/blog/:title", router.Api_V1_GeneralPage(localization, availableLanguages))
+
app.Get("/api/v1/tz", router.Api_V1_TZ())
app.Get("/api/v1/blog-search", router.Api_V1_BlogSearch(localization, availableLanguages, b2Client))
app.Get("/api/v1/like", router.Api_V1_Like_Get(localization, b2Client))
app.Put("/api/v1/like", router.Api_V1_Like_Put(localization, b2Client))
+ app.Get("/api/v1/general-page/header", router.Api_V1_GeneralPage_Header(localization, availableLanguages, b2Client))
+ app.Get("/api/v1/general-page/body", router.Api_V1_GeneralPage_Body(localization, availableLanguages, b2Client, md))
+ app.Get("/api/v1/general-page/footer", router.Api_V1_GeneralPage_Footer(localization, availableLanguages))
+ app.Get("/api/v1/general-page/top-embeds", router.Api_V1_GeneralPage_TopEmbeds(localization, availableLanguages))
+ app.Get("/api/v1/general-page/bottom-embeds", router.Api_V1_GeneralPage_BottomEmbeds(localization, availableLanguages, b2Client))
app.Static("/", "./static")
- log.Fatal(app.Listen(":3000"))
+ go func() {
+ if err := app.Listen(":3000"); err != nil {
+ slog.Error("error while running fiber server", slog.String("error", err.Error()))
+ panic(err)
+ }
+ }()
+
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+ <-sigChan
+ slog.Info("gracefully shutting down...")
+ if err = app.Shutdown(); err != nil {
+ slog.Error("fail to shutdown fiber server", slog.String("error", err.Error()))
+ }
+ if err = router.CCache.Close(); err != nil {
+ slog.Error("fail to dump cache", slog.String("error", err.Error()))
+ }
+ if err = db.Close(); err != nil {
+ slog.Error("fail to close db connection", slog.String("error", err.Error()))
+ }
+ router.PCache.Close()
+ db.Close()
}
diff --git a/migrations/1_create_stats_tables.down.sql b/migrations/1_create_stats_tables.down.sql
new file mode 100644
index 0000000..ee4593c
--- /dev/null
+++ b/migrations/1_create_stats_tables.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS blog_views;
+DROP TABLE IF EXISTS blog_likes;
diff --git a/migrations/1_create_stats_tables.up.sql b/migrations/1_create_stats_tables.up.sql
new file mode 100644
index 0000000..8852268
--- /dev/null
+++ b/migrations/1_create_stats_tables.up.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS blog_likes (
+ page_ref VARCHAR(32) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ PRIMARY KEY (page_ref, user_id)
+) WITHOUT ROWID;
+
+CREATE TABLE IF NOT EXISTS blog_views (
+ page_ref VARCHAR(32) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ PRIMARY KEY (page_ref, user_id)
+) WITHOUT ROWID;
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..33eb226
--- /dev/null
+++ b/static/favicon.ico
Binary files differ
diff --git a/static/input.css b/static/input.css
index f254970..739bea7 100644
--- a/static/input.css
+++ b/static/input.css
@@ -10,6 +10,7 @@
--main-medium-color: #8a9d8a;
--main-light-color: #a5b8a5;
--background-dark-color: #ffffdd;
+ --background-medium-color: var(--background-dark-color);
--background-light-color: #fffff0;
--sidebar-text-color: var(--background-dark-color);
--sidebar-stroke-color: var(--main-dark-color);
@@ -31,6 +32,7 @@
--main-medium-color: #9bb665;
--main-light-color: #bbd095;
--background-dark-color: #f5eee2;
+ --background-medium-color: var(--background-dark-color);
--background-light-color: #fff9ef;
--sidebar-text-color: var(--main-dark-color);
--sidebar-stroke-color: var(--background-dark-color);
@@ -52,6 +54,7 @@
--main-medium-color: var(--color-sky-200);
--main-light-color: var(--color-sky-300);
--background-dark-color: #13131b;
+ --background-medium-color: var(--background-dark-color);
--background-light-color: #222230;
--sidebar-text-color: var(--main-dark-color);
--sidebar-stroke-color: var(--background-light-color);
@@ -73,6 +76,7 @@
--main-medium-color: #f6e4c5;
--main-light-color: #e8d4b8;
--background-dark-color: #413b34;
+ --background-medium-color: #62584f;
--background-light-color: #6d6258;
--sidebar-text-color: var(--main-dark-color);
--sidebar-stroke-color: var(--background-light-color);
@@ -472,42 +476,51 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar {
}
}
+.map-container .leaflet-container {
+ width: 100% !important;
+ height: 100% !important;
+}
+
body[data-theme~="night"] .leaflet-tile,
body[data-theme~="ram"] .leaflet-tile {
filter: invert(1) hue-rotate(190deg) contrast(1.1) brightness(1.2) !important;
}
-@media (min-aspect-ratio: 0.8/1) {
- .ar-gt-0\.8\:mr-\[1\.2vmin\] {
+@media (min-aspect-ratio: 1.1/1) {
+ .ar-gt-1\.1\:mr-\[1\.2vmin\] {
margin-right: 1.2vmin;
}
- .ar-gt-0\.8\:w-fit {
+ .ar-gt-1\.1\:w-fit {
width: fit-content;
}
- .ar-gt-0\.8\:min-w-fit {
+ .ar-gt-1\.1\:w-\[100vh\] {
+ width: 100vh;
+ }
+
+ .ar-gt-1\.1\:min-w-fit {
min-width: fit-content;
}
- .ar-gt-0\.8\:min-w-\[10vh\] {
+ .ar-gt-1\.1\:min-w-\[10vh\] {
min-width: 10vh;
}
- .ar-gt-0\.8\:max-w-\[20vh\] {
+ .ar-gt-1\.1\:max-w-\[20vh\] {
max-width: 20vh;
}
- .ar-gt-0\.8\:max-w-\[100vh\] {
+ .ar-gt-1\.1\:max-w-\[100vh\] {
max-width: 100vh;
}
- .ar-gt-0\.8\:items-center {
+ .ar-gt-1\.1\:items-center {
align-items: center;
}
}
-@media (max-aspect-ratio: 0.8/1) {
+@media (max-aspect-ratio: 1.1/1) {
.bg-sidebar {
background-size: calc(var(--squares-and-triangles-background-width-coef) * 1.6vh) calc(var(--squares-and-triangles-background-height-coef) * 1.6vh);
}
@@ -537,71 +550,91 @@ body[data-theme~="ram"] .leaflet-tile {
background-size: 10vh;
}
- .ar-lt-0\.8\:grid {
+ .ar-lt-1\.1\:grid {
display: grid;
}
- .ar-lt-0\.8\:flex-col {
+ .ar-lt-1\.1\:flex-col {
flex-direction: column;
}
- .ar-lt-0\.8\:flex-row {
+ .ar-lt-1\.1\:flex-row {
flex-direction: row;
}
- .ar-lt-0\.8\:h-\[8vh\] {
+ .ar-lt-1\.1\:h-\[8vh\] {
height: 8vh;
}
- .ar-lt-0\.8\:h-\[10vh\] {
+ .ar-lt-1\.1\:h-\[10vh\] {
height: 10vh;
}
- .ar-lt-0\.8\:h-\[30vh\] {
+ .ar-lt-1\.1\:h-\[30vh\] {
height: 30vh;
}
- .ar-lt-0\.8\:h-\[90vh\] {
+ .ar-lt-1\.1\:h-\[90vh\] {
height: 90vh;
}
- .ar-lt-0\.8\:w-\[8vh\] {
+ .ar-lt-1\.1\:w-\[8vh\] {
width: 8vh;
}
- .ar-lt-0\.8\:w-\[80\%\] {
+ .ar-lt-1\.1\:w-\[80\%\] {
width: 80%;
}
- .ar-lt-0\.8\:w-\[90vw\] {
+ .ar-lt-1\.1\:w-\[90vw\] {
width: 90vw;
}
- .ar-lt-0\.8\:w-screen {
+ .ar-lt-1\.1\:w-screen {
width: 100vw;
}
- .ar-lt-0\.8\:max-h-\[80\%\] {
+ .ar-lt-1\.1\:max-h-\[25\%\] {
+ max-height: 25%;
+ }
+
+ .ar-lt-1\.1\:max-h-\[80\%\] {
max-height: 80%;
}
- .ar-lt-0\.8\:max-h-\[calc\(100\%-25vh\)\] {
+ .ar-lt-1\.1\:max-h-\[calc\(100\%-25vh\)\] {
max-height: calc(100% - 25vh);
}
- .ar-lt-0\.8\:text-\[5vh\] {
+ .ar-lt-1\.1\:text-\[5vh\] {
font-size: 5vh;
}
- .ar-lt-0\.8\:ml-\[5vw\] {
+ .ar-lt-1\.1\:ml-\[5vw\] {
margin-left: 5vw;
}
- .ar-lt-0\.8\:hidden {
+ .ar-lt-1\.1\:hidden {
display: none;
}
}
+@media (min-aspect-ratio: 0.6/1) {
+ .ar-gt-0\.6\:flex-3\/5 {
+ flex: 60%;
+ }
+
+ .ar-gt-0\.6\:flex-2\/5 {
+ flex: 40%;
+ }
+}
+
+@media (max-aspect-ratio: 0.6/1) {
+ .ar-lt-0\.6\:flex-col {
+ flex-direction: column;
+ }
+}
+
@media (max-aspect-ratio: 0.4/1) {
.ar-lt-0\.4\:w-\[100vw\] {
width: 100vw;
@@ -612,6 +645,14 @@ body[data-theme~="ram"] .leaflet-tile {
}
}
+.home-page-heart {
+ position: absolute;
+ color: var(--color-pink-700);
+ user-select: none;
+ pointer-events: none;
+ font-family: serif;
+}
+
.night-star {
color: var(--color-yellow-100);
animation-name: blink;
diff --git a/views/index.html b/views/index.html
index 2b8c937..b52df94 100644
--- a/views/index.html
+++ b/views/index.html
@@ -106,7 +106,7 @@
<p class="font-patua mx-auto text-[2.5vmax] mt-[0.8vmin] mb-[0.8vmin]">saya.today</p>
<div class="menu-inner-body grow bg-background-light flex flex-adaptive mx-auto opacity-100">
{{- range .AvailableLanguages }}
- <img onclick="location.href='/{{ .Name }}/blog';" class="object-scale-down cursor-pointer flex-1 rounded-[20%] language-icon mx-auto my-auto relative" src="{{ .Flag }}" alt="{{ .Alt }}">
+ <img onclick="location.href='/{{ .Name }}';" class="object-scale-down cursor-pointer flex-1 rounded-[20%] language-icon mx-auto my-auto relative" src="{{ .Flag }}" alt="{{ .Alt }}">
{{- end }}
</div>
<p class="font-spectral mx-auto text-[1.5vmax] text-secondary mt-[0.8vmin] mb-[0.8vmin]">Choose your language</p>
diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html
index a7cf66e..60608f4 100644
--- a/views/layouts/general-page.html
+++ b/views/layouts/general-page.html
@@ -3,16 +3,28 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>SAYA TODAY // {{ .Title }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet">
- {{ block "top-embeds" . }}{{ end }}
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/css/lightgallery.min.css">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/css/lg-zoom.min.css">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/css/lg-thumbnail.min.css">
+ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css">
<link href="/output.css" rel="stylesheet">
</head>
-<body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-0.8:flex-col">
+<body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-1.1:flex-col">
+
+ <script>
+ function changeUrl(path, toAppend = false) {
+ const urlParams = new URLSearchParams(window.location.search);
+ const newPathname = location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname;
+ const newPath = toAppend ? `${newPathname}${path}` : `${path}`;
+ history.pushState({}, '', `${newPath}${urlParams.entries.length == 0 ? '' : '?' + urlParams.toString()}`);
+ window.dispatchEvent(new Event('popstate'));
+ };
+ </script>
<script>
function sfc32(a, b, c, d) {
@@ -140,19 +152,24 @@
switchTheme(savedTheme);
</script>
+ <div id="general-page-top-embeds" class="flex"
+ hx-get="/api/v1/general-page/top-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-top-embeds-trigger" hx-get="/api/v1/general-page/top-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-top-embeds" hx-swap="innerHTML"></div>
+
<script>
const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
</script>
- <div id="sidebar" class="bg-sidebar flex sticky flex-col ar-gt-0.8:items-center shadow-2xl z-20 w-[5vw] ar-lt-0.8:w-[90vw] ar-lt-0.4:w-[100vw] h-screen ar-lt-0.8:h-[8vh] ar-lt-0.8:ml-[5vw] ar-lt-0.4:ml-0">
- <div class="logo-custom ar-lt-0.8:hidden text-sidebar font-patua font-extrabold text-center relative z-20">
+ <div id="sidebar" class="bg-sidebar flex sticky flex-col ar-gt-1.1:items-center shadow-2xl z-20 w-[5vw] ar-lt-1.1:w-[90vw] ar-lt-0.4:w-[100vw] h-screen ar-lt-1.1:h-[8vh] ar-lt-1.1:ml-[5vw] ar-lt-0.4:ml-0">
+ <div class="logo-custom ar-lt-1.1: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 class="text-[3vw] ar-lt-1.1:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-1.1:flex-row gap-0 relative z-20">
+ <i onclick="changeUrl('/{{ .Lang }}');" class="fas fa-home w-[5vw] ar-lt-1.1:w-[8vh] h-[5vw] ar-lt-1.1:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i>
+ <i onclick="changeUrl('/{{ .Lang }}/blog');" class="fas fa-search w-[5vw] ar-lt-1.1:w-[8vh] h-[5vw] ar-lt-1.1: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-1.1:w-[8vh] h-[5vw] ar-lt-1.1: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-1.1:w-[8vh] h-[5vw] ar-lt-1.1: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>
@@ -162,31 +179,26 @@
</div>
</div>
- <div class="bg-background-dark flex z-10 ar-lt-0.8:w-[90vw] ar-lt-0.4:w-[100vw] h-screen ar-lt-0.8:ml-[5vw] ar-lt-0.4:ml-0">
+ <div class="bg-background-dark flex z-10 ar-lt-1.1:w-[90vw] ar-lt-0.4:w-[100vw] h-screen ar-lt-1.1:ml-[5vw] ar-lt-0.4:ml-0">
<div class="pl-[0.5vw] bg-background-dark border-r-[0.4vmin] border-dashed border-main-dark"></div>
- <div class="ar-gt-0.8:min-w-fit ar-gt-0.8:max-w-[100vh] flex flex-col bg-background-dark relative pt-[1.6vmin] ml-[1.6vmin] pr-[1.6vmin]">
- <div {{ if .PublishedDate }} hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="#published-date" hx-swap="innerHTML" hx-trigger="load" {{ end }}
- class="flex flex-row mb-[0.4vmin]">
- <i onclick="location.pathname += location.pathname.endsWith('/') ? '../' : '/../';" class="fas fa-circle-left hover:bg-main-dark hover:bg-opacity-10 cursor-pointer transition-colors duration-300 rounded-md text-[2vmax] mt-auto mb-auto px-[0.8vmin]"></i>
- <p class="text-[2vmax] font-spectral italic font-extrabold select-none mt-auto pl-[0.8vmin] pr-[1.2vmin]">//</p>
- <p class="text-[2vmax] font-spectral italic text-left mt-auto">{{ .Title }}</p>
- <p id="published-date" class="grow text-[1.5vmax] font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p>
+ <div class="ar-gt-1.1:w-[100vh] flex flex-col bg-background-dark relative pt-[1.6vmin] ml-[1.6vmin] pr-[1.6vmin]">
+ <div id="general-page-header" class="flex flex-col"
+ hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
</div>
- {{ block "header" . }}{{ end }}
+ <div id="general-page-header-trigger" hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-header" hx-swap="innerHTML"></div>
+
<hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]">
- {{ block "body" . }}{{ end }}
+ <div id="general-page-body" class="flex flex-col max-h-[calc(100%-7vmax)] ar-lt-1.1:max-h-[calc(100%-25vh)] grow"
+ hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-body-trigger" hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-body" hx-swap="innerHTML"></div>
- <div class="flex flex-row mt-[0.8vmin] mb-[0.4vmin]">
- {{ 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 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 id="general-page-footer" class="flex flex-row mt-[0.8vmin] mb-[0.4vmin]"
+ hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
</div>
+ <div id="general-page-footer-trigger" hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-footer" hx-swap="innerHTML"></div>
<hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]">
</div>
@@ -195,6 +207,15 @@
</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/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>
+ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script>
+
+ <div id="general-page-bottom-embeds" class="flex"
+ hx-get="/api/v1/general-page/bottom-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML">
+ </div>
+ <div id="general-page-bottom-embeds-trigger" hx-get="/api/v1/general-page/bottom-embeds{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-bottom-embeds" hx-swap="innerHTML"></div>
<script>
const themeWheel = document.getElementById('theme-wheel');
@@ -315,7 +336,5 @@
});
</script>
- {{ block "bottom-embeds" . }}{{ end }}
-
</body>
</html>
diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html
index 25b0685..eccafd6 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]">
+<div class="flex flex-row ar-lt-1.1:flex-col text-[1vmax] h-[100%]">
<form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML"
- class="tags-list ar-gt-0.8:min-w-[10vh] ar-gt-0.8:max-w-[20vh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] flex shrink-0 flex-col overflow-y-auto ar-gt-0.8:mr-[1.2vmin]">
- <fieldset>
- <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.TagsHeader }}</legend>
- <div class="flex flex-col ar-lt-0.8:grid grid-cols-5 grid-flow-row-dense">
- <div class="m-[0.4vmin]">
- <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label>
+ class="tags-list ar-gt-1.1:min-w-[10vh] ar-gt-1.1:max-w-[20vh] ar-gt-1.1:w-fit ar-lt-1.1:w-[100%] ar-lt-1.1:max-h-[25%] flex shrink-0 flex-col ar-gt-1.1: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-1.1: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 class="m-[0.4vmin]">
- <label><input type="checkbox" id="tag{{ .Name }}Checkbox" name="tags[]" value="{{ .Name }}" onclick="contextAll();" {{ if contains $.QueryTags .Name }}checked{{ end }}> {{ .Name }} <span class="text-secondary italic">{{ .Count }}</span></label>
+ </fieldset>
+ <fieldset class="mt-[0.8vmin]">
+ <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.OrderByHeader }}</legend>
+ <div class="flex flex-col ar-lt-1.1: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">
- <div class="m-[0.4vmin]">
- <label><input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-a-z"></i></label>
- </div>
- <div class="m-[0.4vmin]">
- <label><input type="radio" id="sortTitleDesc" name="sort" value="titleDesc" {{ if eq .QuerySort "titleDesc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-z-a"></i></label>
- </div>
- <div class="m-[0.4vmin]">
- <label><input type="radio" id="sortActionDateAsc" name="sort" value="actionDateAsc" {{ if eq .QuerySort "actionDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label>
- </div>
- <div class="m-[0.4vmin]">
- <label><input type="radio" id="sortActionDateDesc" name="sort" value="actionDateDesc" {{ if eq .QuerySort "actionDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label>
- </div>
- <div class="m-[0.4vmin]">
- <label><input type="radio" id="sortPublicationDateAsc" name="sort" value="publicationDateAsc" {{ if eq .QuerySort "publicationDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label>
- </div>
- <div class="m-[0.4vmin]">
- <label><input type="radio" id="sortPublicationDateDesc" name="sort" value="publicationDateDesc" {{ if eq .QuerySort "publicationDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label>
- </div>
- </div>
- </fieldset>
- <div class="flex flex-row bg-background-light my-[2vmin] p-[0.4vmin] rounded-[0.4vmin] justify-items-center">
+ </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">
@@ -54,9 +57,7 @@
Loading...
</div>
</div>
-{{ end }}
-{{ define "bottom-embeds" }}
<script>
function selectAll() {
const tagsContainers = document.querySelectorAll('form.tags-list');
@@ -97,6 +98,7 @@
function cleanHrefParams() {
const url = new URL(window.location.href);
url.search = '';
+
window.history.pushState({}, '', url.toString());
}
@@ -136,23 +138,27 @@
window.history.pushState({}, '', url.toString());
}
- document.addEventListener('DOMContentLoaded', contextAll);
-</script>
+ document.addEventListener('htmx:afterRequest', (e) => {
+ if (e.detail.xhr.status == 404 || e.detail.successful != true) {
+ return console.error(e);
+ }
+ if (e.detail.target.id == 'general-page-body') {
+ contextAll();
-<script>
- const tagsContainers = document.querySelectorAll('form.tags-list');
- tagsContainers.forEach(tagsContainer => {
- const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]');
- anyChecked = false;
- checkboxes.forEach(checkbox => {
- anyChecked = checkbox.checked ? true : anyChecked;
- });
- if (!anyChecked) {
- const selectAllCheckbox = tagsContainer.querySelector('#tagsAllCheckbox');
- selectAllCheckbox.checked = true;
- selectAll();
+ const tagsContainers = document.querySelectorAll('form.tags-list');
+ tagsContainers.forEach(tagsContainer => {
+ const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]');
+ anyChecked = false;
+ checkboxes.forEach(checkbox => {
+ anyChecked = checkbox.checked ? true : anyChecked;
+ });
+ if (!anyChecked) {
+ const selectAllCheckbox = tagsContainer.querySelector('#tagsAllCheckbox');
+ selectAllCheckbox.checked = true;
+ selectAll();
+ }
+ });
}
- });
+ }, {once: true});
</script>
-
{{ end }}
diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html
index 65ee31d..863b417 100644
--- a/views/pages/blog-page.html
+++ b/views/pages/blog-page.html
@@ -1,30 +1,64 @@
{{ 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">
-{{- if .MapLocationX }}
-<link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css">
-{{- end }}
-
-<style>
- .map-container .leaflet-container {
- width: 100% !important;
- height: 100% !important;
- }
-</style>
-
<script>
- const galleryMap = new Map();
+ let galleryMap = new Map();
+ let map;
+
+ window.addEventListener('popout', (e) => {
+ map.remove();
+ galleryMap.forEach((_, g, m) => {
+ g.destroy();
+ });
+ galleryMap.clear();
+ map = null;
+ galleryMap = null;
+ }, {once: true});
</script>
{{ end }}
{{ define "body" }}
-<div class="bg-background-light flex flex-col grow overflow-y-auto inset-shadow p-[2.4vmin] max-h-[calc(100%-7vmax)] ar-lt-0.8:max-h-[calc(100%-25vh)]">
+<div class="bg-background-light flex flex-col inset-shadow p-[2.4vmin] overflow-y-auto">
{{ .ParsedMarkdown }}
{{- if .MapLocationX }}
<hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%] ml-auto mr-auto">
- <div id="map-container" class="relative p-[0.8vmin] flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40vh] ar-lt-0.8:h-[30vh]"></div>
+ <div id="map-container" class="relative p-[0.8vmin] flex-none ml-auto mr-auto w-[60%] ar-lt-1.1:w-[80%] h-[40vh] ar-lt-1.1: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">
+
+ <script>
+ function initLocationMap(id, x, y, relativeErrorMeters = 0) {
+ map = L.map(id).setView([x, y], 8);
+
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+ }).addTo(map);
+
+ if (relativeErrorMeters != 0) {
+ var circle = L.circle([x, y], {
+ color: '#8a9d8a',
+ fillColor: '#8a9d8a',
+ fillOpacity: 0.15,
+ radius: relativeErrorMeters
+ }).addTo(map);
+ }
+
+ var marker = L.marker([x, y], {
+ title: '{{ .Title }}',
+ }).addTo(map)
+ .bindPopup('<span><b>{{ .Title }}</b><br><a href="https://www.openstreetmap.org/#map=13/{{ .MapLocationX }}/{{ .MapLocationY }}">{{ .MapLocationX }}, {{ .MapLocationY }}</a></span>')
+ .openPopup();
+ }
+
+ document.addEventListener('htmx:afterRequest', (e) => {
+ if (e.detail.target.id == 'general-page-body') {
+ initLocationMap(
+ 'map-container',
+ {{ .MapLocationX }},
+ {{ .MapLocationY }},
+ {{ .MapLocationAreaMeters }}
+ );
+ }
+ }, {once: true});
+ </script>
{{- end }}
</div>
{{ end }}
@@ -34,51 +68,15 @@
{{ 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>
-{{- if .MapLocationX }}
-<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) {
- map = L.map(id).setView([x, y], 8);
-
- L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
- maxZoom: 18,
- attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
- }).addTo(map);
-
- if (relativeErrorMeters != 0) {
- var circle = L.circle([x, y], {
- color: '#8a9d8a',
- fillColor: '#8a9d8a',
- fillOpacity: 0.15,
- radius: relativeErrorMeters
- }).addTo(map);
- }
-
- var marker = L.marker([x, y], {
- title: '{{ .Title }}',
- }).addTo(map)
- .bindPopup('<span><b>{{ .Title }}</b><br><a href="https://www.openstreetmap.org/#map=13/{{ .MapLocationX }}/{{ .MapLocationY }}">{{ .MapLocationX }}, {{ .MapLocationY }}</a></span>')
- .openPopup();
- }
-
- document.addEventListener('DOMContentLoaded', () => {initLocationMap(
- 'map-container',
- {{ .MapLocationX }},
- {{ .MapLocationY }},
- {{ .MapLocationAreaMeters }}
- )});
-
let galleryResizeTimeout;
- window.addEventListener('resize', function() {
+
+ function resizeCb() {
clearTimeout(galleryResizeTimeout);
galleryResizeTimeout = setTimeout(() => {
- map.invalidateSize();
+ if (map !== undefined) {
+ map.invalidateSize();
+ }
var oldGalleryMap = new Map(galleryMap);
oldGalleryMap.forEach((createFunc, g, map) => {
galleryMap.delete(g);
@@ -86,7 +84,13 @@
createFunc();
});
}, 300);
+ }
+
+ window.addEventListener('resize', resizeCb);
+
+ document.addEventListener('popout', (e) => {
+ window.removeEventListener('resize', resizeCb);
+ galleryResizeTimeout = null;
});
</script>
-{{- end }}
{{ end }} \ No newline at end of file
diff --git a/views/pages/global-map.html b/views/pages/global-map.html
index 569166f..a844e06 100644
--- a/views/pages/global-map.html
+++ b/views/pages/global-map.html
@@ -15,7 +15,7 @@
<link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css">
</head>
-<body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-0.8:flex-col">
+<body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-1.1:flex-col">
<script>
function switchTheme(theme) {
@@ -40,15 +40,15 @@
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">
+ <div id="sidebar" class="bg-sidebar flex sticky flex-col ar-gt-1.1:items-center shadow-2xl z-20 w-[5vw] ar-lt-1.1:w-screen h-screen ar-lt-1.1:h-[8vh]">
+ <div class="logo-custom ar-lt-1.1: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 class="text-[3vw] ar-lt-1.1:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-1.1:flex-row gap-0 relative z-20">
+ <i onclick="location.href='/{{ .Lang }}';" class="fas fa-home w-[5vw] ar-lt-1.1:w-[8vh] h-[5vw] ar-lt-1.1: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-1.1:w-[8vh] h-[5vw] ar-lt-1.1: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-1.1:w-[8vh] h-[5vw] ar-lt-1.1: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-1.1:w-[8vh] h-[5vw] ar-lt-1.1: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>
@@ -58,7 +58,7 @@
</div>
</div>
- <div id="map-container" class="bg-background-dark flex z-10 w-[95vw] ar-lt-0.8:w-screen h-screen ar-lt-0.8:h-[92vh]"></div>
+ <div id="map-container" class="bg-background-dark flex z-10 w-[95vw] ar-lt-1.1:w-screen h-screen ar-lt-1.1: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>
diff --git a/views/pages/home-page.html b/views/pages/home-page.html
new file mode 100644
index 0000000..6fd716b
--- /dev/null
+++ b/views/pages/home-page.html
@@ -0,0 +1,121 @@
+{{ define "body" }}
+<div class="bg-background-light flex flex-row ar-lt-0.6:flex-col w-full h-full inset-shadow overflow-y-auto text-[1vmax]/[2] tracking-[0.04vmin]">
+ <div class="ar-gt-0.6:flex-3/5 justify-center content-start p-[5%]">
+ <div class="flex flex-col justify-start items-start bg-background-dark mb-[2vmax]">
+ <p class="text-[3vmax]/[2] tracking-[0.2vmin] uppercase w-full font-spectral italic text-left
+ border-l-[0.4vmax] border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Hymn1 }}</p>
+ <hr class="w-full border-t-[0.2vmax] border-dotted border-main-dark mb-[0.8vmin]">
+ <p class="text-[2vmax]/[2] -indent-[2vmax] ml-[2vmax] p-[0.4vmax]">{{ .L.HomePage.Hymn2 }}</p>
+ <p class="text-[2vmax]/[2] -indent-[2vmax] ml-[2vmax] p-[0.4vmax]">{{ .L.HomePage.Hymn3 }}</p>
+ <p class="text-[2vmax]/[2] -indent-[2vmax] ml-[2vmax] p-[0.4vmax]">{{ .L.HomePage.Hymn4 }}</p>
+ </div>
+
+ <div class="flex flex-col justify-center items-center">
+ <p class="text-[2vmax] w-full font-spectral italic text-left
+ border-l-[0.4vmax] border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.DidYouKnowThat }}</p>
+ <hr class="w-full border-t-[0.2vmax] border-dotted border-main-dark mb-[0.8vmin]">
+ <ul class="w-full text-left list-[circle]">
+ <li class="ml-[2vmax] mb-[0.8vmin]">{{ index .FunFacts 0 }}</li>
+ <li class="ml-[2vmax] mb-[0.8vmin]">{{ index .FunFacts 1 }}</li>
+ <li class="ml-[2vmax]">{{ index .FunFacts 2 }}</li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="ar-gt-0.6:flex-2/5 justify-center content-start p-[5%]">
+ <div class="flex flex-col justify-center items-start mb-[2vmax]">
+ <p class="text-[2vmax] w-full font-spectral italic text-left
+ border-l-[0.4vmax] border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.Contacts }}</p>
+ <a href="https://t.me/EarlInisMona">
+ <i class="fa-brands fa-telegram w-[1vmax] h-[1vmax] mr-[0.4vmax]"></i>
+ <span class="font-bold">Telegram:</span>
+ <span>@EarlInisMona</span>
+ </a>
+ <a href="https://discord.com/users/356057270951346176">
+ <i class="fa-brands fa-discord w-[1vmax] h-[1vmax] mr-[0.4vmax]"></i>
+ <span class="font-bold">Discord:</span>
+ <span>montferrat</span>
+ </a>
+ <a href="mailto:saya.andy@posteo.com">
+ <i class="fas fa-envelope w-[1vmax] h-[1vmax] mr-[0.4vmax]"></i>
+ <span class="font-bold">E-mail:</span>
+ <span>saya.andy@posteo.com</span>
+ </a>
+ </div>
+
+ <div class="flex relative aspect-square justify-center content-center mb-[2vmax]">
+ {{- range $_ := iterate .FilledHeartCount }}
+ <div class="home-page-heart z-50">♥</div>
+ {{- end }}
+ {{- range $_ := iterate .OutlineHeartCount }}
+ <div class="home-page-heart z-50">♡</div>
+ {{- end }}
+ <img src="https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/{{ .GifName }}"
+ class="home-page-gif z-49 -rotate-12 max-w-[70%] max-h-[70%] border-[1vmin] border-(--background-dark-color) border-inset">
+ </div>
+
+ <script>
+ function shuffleHearts() {
+ vmin = calculateVmin(1);
+ document.querySelectorAll('.home-page-heart').forEach((e) => {
+ let top, left;
+ do {
+ top = Math.floor(rand() * 90) + 5;
+ left = Math.floor(rand() * 90) + 5;
+ } while (top > 30 && top < 70 && left > 30 && left < 70);
+ const opacity = rand() * 0.7 + 0.2;
+ const size = rand() * 3.2 + 0.8;
+ e.style["top"] = `calc(${top}% - ${size}vmin / 2)`;
+ e.style["left"] = `calc(${left}% - ${size}vmin / 2)`;
+ e.style["opacity"] = `${opacity}`;
+ e.style["font-size"] = `${size}vmin`;
+ });
+ }
+
+ let shuffleHeartsResizeTimeout;
+ function shuffleHeartsResizeCb() {
+ clearTimeout(shuffleHeartsResizeTimeout);
+ shuffleHeartsResizeTimeout = setTimeout(shuffleHearts, 300);
+ }
+ window.addEventListener('resize', shuffleHeartsResizeCb);
+
+ document.addEventListener('popout', (e) => {
+ window.removeEventListener('resize', shuffleHeartsResizeCb);
+ shuffleHeartsResizeCb = null;
+ });
+ </script>
+
+ <script>
+ document.addEventListener('htmx:afterRequest', (e) => {
+ if (e.detail.xhr.status == 404 || e.detail.successful != true) {
+ return console.error(e);
+ }
+ if (e.detail.target.id == 'general-page-body') {
+ shuffleHearts();
+ }
+ }, {once: true});
+ </script>
+
+ <div class="flex flex-col justify-center items-center">
+ <p class="text-[2vmax] w-full font-spectral italic text-left
+ border-l-[0.4vmax] border-solid border-(--background-dark-color)
+ bg-gradient-to-r from-(--background-dark-color) to-(--background-light-color)">{{ .L.HomePage.SidebarDescription }}</p>
+ <hr class="w-full border-t-[0.2vmax] border-dotted border-main-dark mb-[0.8vmin]">
+ <div class="w-full grid grid-cols-[1fr_4fr] content-center items-center
+ [&>*:nth-child(4n+2)]:bg-(--background-medium-color) [&>*:nth-child(4n+3)]:bg-(--background-medium-color)" style="container-type: inline-size;">
+ <i class="fas fa-home w-full h-full text-[10cqi] content-center text-center text-main-dark"></i>
+ <p class="p-[0.4vmax]">{{ .L.HomePage.HomePageDescription }}</p>
+ <i class="fas fa-search w-full h-full text-[10cqi] content-center text-center text-main-dark"></i>
+ <p class="p-[0.4vmax]">{{ .L.HomePage.BlogSearchDescription }}</p>
+ <i class="fas fa-map w-full h-full text-[10cqi] content-center text-center text-main-dark"></i>
+ <p class="p-[0.4vmax]">{{ .L.HomePage.MarkerMapDescription }}</p>
+ <i class="fas fa-swatchbook w-full h-full text-[10cqi] content-center text-center text-main-dark"></i>
+ <p class="p-[0.4vmax]">{{ .L.HomePage.ThemeSwitchDescription }}</p>
+ </div>
+ </div>
+ </div>
+</div>
+{{ end }}
diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html
index afc3027..a16e8c0 100644
--- a/views/partials/blog-page-like-button.html
+++ b/views/partials/blog-page-like-button.html
@@ -1,7 +1,6 @@
<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>
+ <i class="fas fa-thumbs-up w-[0.8vmax] h-[0.8vmax]"></i>
<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 62e931a..37b3f57 100644
--- a/views/partials/catalogue-blog-cards.html
+++ b/views/partials/catalogue-blog-cards.html
@@ -1,10 +1,10 @@
{{- range .BlogPages }}
<hr class="first-of-type:hidden border-t-[0.3vh] my-[0.8vmin] border-dotted border-main-dark w-[80%] mx-auto">
<div class="m-[0.4vmin] flex flex-row justify-center justify-items-center">
- <img onclick="location.href='{{ .ArticleLink }}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[10vmin] h-[10vmin] aspect-square my-[0.4vmin] mr-[0.8vmin]" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp">
+ <img onclick="changeUrl('{{ .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">
+ <a onclick="changeUrl('{{ .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>
@@ -12,6 +12,13 @@
<p class="flex-3/12 grow-0 text-[1vmax] italic text-secondary text-right">{{ .PublishedTime }}</p>
</div>
<p class="text-[1vmax] text-main-dark">{{ .ShortDescription }}</p>
+ <p class="text-[1vmax] text-secondary italic">
+ <i class="fas fa-thumbs-up w-[1vmax] h-[1vmax]"></i>
+ <span>{{ .LikeCount }}</span>
+ <span class="font-extrabold select-none">//</span>
+ <i class="fas fa-eye w-[1vmax] h-[1vmax]"></i>
+ <span>{{ .ViewCount }}</span>
+ </p>
<p class="text-[1vmax] text-secondary">{{ $.L.TagsLabel }} {{ template "catalogue-blog-card-tags.html" . }}</p>
</div>
</div>
diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html
new file mode 100644
index 0000000..4686ea1
--- /dev/null
+++ b/views/partials/general-page-body.html
@@ -0,0 +1,2 @@
+<title>SAYA TODAY // {{ .Title }}</title>
+{{ block "body" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/general-page-bottom-embeds.html b/views/partials/general-page-bottom-embeds.html
new file mode 100644
index 0000000..4c36b26
--- /dev/null
+++ b/views/partials/general-page-bottom-embeds.html
@@ -0,0 +1 @@
+{{ block "bottom-embeds" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/general-page-footer.html b/views/partials/general-page-footer.html
new file mode 100644
index 0000000..af6aec9
--- /dev/null
+++ b/views/partials/general-page-footer.html
@@ -0,0 +1,7 @@
+{{ 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 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"> \ No newline at end of file
diff --git a/views/partials/general-page-header.html b/views/partials/general-page-header.html
new file mode 100644
index 0000000..6cb87b7
--- /dev/null
+++ b/views/partials/general-page-header.html
@@ -0,0 +1,8 @@
+<div {{ if .PublishedDate }} hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="#published-date" hx-swap="innerHTML" hx-trigger="load" {{ end }}
+ class="flex flex-row mb-[0.4vmin]">
+ <i onclick="changeUrl('/../', true);" class="fas fa-circle-left hover:bg-main-dark hover:bg-opacity-10 cursor-pointer transition-colors duration-300 rounded-md text-[2vmax] mt-auto mb-auto px-[0.8vmin]"></i>
+ <p class="text-[2.5vmin] font-spectral italic font-extrabold select-none mt-auto pl-[0.8vmin] pr-[1.2vmin]">//</p>
+ <p class="text-[2.5vmin] font-spectral italic text-left mt-auto">{{ .Title }}</p>
+ <p id="published-date" class="grow text-[1.75vmin] font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p>
+</div>
+{{ block "header" . }}{{ end }}
diff --git a/views/partials/general-page-top-embeds.html b/views/partials/general-page-top-embeds.html
new file mode 100644
index 0000000..1407b50
--- /dev/null
+++ b/views/partials/general-page-top-embeds.html
@@ -0,0 +1 @@
+{{ block "top-embeds" . }}{{ end }} \ No newline at end of file