diff --git a/README.md b/README.md index d556891..ff9ffd4 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Post text… | `date` | date | yes | Publish date. "Newest" is sorted by it. | | `updated` | date | no | Last update. Shown on the post and in RSS. | | `tags` | list | no | Tags. A single string also works. | -| `cover` | string | no | Cover image, a file inside `assets/`. | +| `cover` | string | no | Cover image, a file inside `assets/`. Shown as a full-width banner above the title, cropped to a wide strip around the center: keep the subject in the middle. | | `slug` | string | no | URL part. Default: made from the file name. | | `lang` | string | no | Post language, two lower-case letters (`en`, `ru`). Default: `DEFAULT_LANG`. | | `draft` | bool | no | `true` keeps the post unpublished. Default `false`. | diff --git a/README.ru.md b/README.ru.md index 417d24c..50caea6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -166,7 +166,7 @@ draft: false | `date` | дата | да | Дата публикации. По ней сортируется «Новое». | | `updated` | дата | нет | Дата обновления. Показывается в посте и в RSS. | | `tags` | список | нет | Теги. Одна строка тоже подходит. | -| `cover` | строка | нет | Обложка, файл внутри `assets/`. | +| `cover` | строка | нет | Обложка, файл внутри `assets/`. Показывается баннером на всю ширину над заголовком и обрезается до широкой полосы вокруг центра: держите главное посередине. | | `slug` | строка | нет | Часть адреса. По умолчанию — из имени файла. | | `lang` | строка | нет | Язык поста, две строчные буквы (`en`, `ru`). По умолчанию `DEFAULT_LANG`. | | `draft` | bool | нет | `true` — пост не публикуется. По умолчанию `false`. | diff --git a/SPEC.md b/SPEC.md index eb4e356..49d0c75 100644 --- a/SPEC.md +++ b/SPEC.md @@ -67,7 +67,7 @@ The author writes in Obsidian and publishes by syncing the vault to the bucket. ### Post page - Markdown from S3 rendered to HTML. -- Cover (`cover`), if set. +- Cover (`cover`), if set: a banner above the title, right under the top bar, full page width (not the text column). Fixed height `min(30vh, 320px)`, `min(25vh, 200px)` on narrow screens; `object-fit: cover`, centered; no rounded corners. Title, dates and tags stay below it in the text column. The `` keeps `width`/`height`. No cover: no banner. - Estimated reading time. - View count. - Likes and dislikes. @@ -617,3 +617,4 @@ Other methods → `405`. Unknown paths → `404`. - 2026-11-04 — Metrics, votes, limits: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). All-time counters per post and label (Grafana gets daily trends with `increase()`), daily views only for "Popular" (pruned after `POPULAR_DAYS`). Search queries: live requests from `search.js` (`X-Search: live`) are not stored, the settled query is; TTL 90 days, at most 10 000 rows, top 20 in `/metrics`, refreshed every 5 min. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, v2 challenge with SHA-256, cost 1, `uint32` counter up to `ALTCHA_MAX_NUMBER`, key signature; own small solver `static/altcha.js` instead of the widget; signing keys derived from `ALTCHA_HMAC_KEY`; replay map capped at 100 000. Beacon: a delta on every hide and `pagehide`, sent with `fetch(…, {keepalive: true})` (JSON body), each with a random per-page-view id; the server sums time per view and clamps the sum to 4 h; time on page = average over views with reading time. Views and read depth: once per visitor per post per day; daily hashes in memory only (cap 200 000), `ip_prefix` = IPv4 address or IPv6 `/64`. Source `internal` for referrers from the site itself. Outbound hosts limited to links in the post. `/api/altcha` and `/api/vote` share `RATE_LIMIT_VOTE`; limiter tracks at most 10 000 clients. Vote cookie stored as SHA-256. "Popular" after "Newest" on page 1. `/metrics` in Prometheus text format written with the standard library. - 2026-11-04 — Grafana, Caddy, README: dashboard `grafana/hyperhack.json` for a Prometheus-type data source on VictoriaMetrics (uses the VictoriaMetrics function `sort_by_label_numeric`); daily bars cover the day that starts at the bar, so today is shown. `Caddyfile.example` for Caddy `v2.11.7` with `caddy-ratelimit` `v0.1.0` (1200 requests per minute per client IP; `/static/*` and `/assets/*` are not counted) and `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` and `/reindex` (and their subpaths) answer `404` on the public domain; compression `zstd`/`gzip`. README: behind Caddy on the host, `TRUSTED_PROXIES` is the gateway of the Compose network. Full README in English and Russian. - 2026-11-04 — Production Compose: image registry GHCR (confirmed). `docker-compose.yml` only pulls the released image, no `build:`, pinned to the latest release (`0.1.0`; image tags are the git tag without `v`). Local builds in `compose.dev.yml`, used only with `-f`; no `docker-compose.override.yml`. README Quick start uses the downloaded `docker-compose.yml` as is, and README documents upgrades and rollback. +- 2026-11-04 — Post cover as a full-width banner between the top bar and the text column: height `min(30vh, 320px)` (`min(25vh, 200px)` on screens up to 30rem), `object-fit: cover` centered, no rounded corners, `width`/`height` kept. Rendered from a `banner` block in the layout, outside `
`. Posts without cover unchanged. diff --git a/SPEC.ru.md b/SPEC.ru.md index 2026f52..bc601d4 100644 --- a/SPEC.ru.md +++ b/SPEC.ru.md @@ -67,7 +67,7 @@ ### Пост - Рендер Markdown из S3. -- Обложка (`cover`), если задана. +- Обложка (`cover`), если задана: баннер над заголовком, сразу под верхней панелью, на всю ширину страницы (не колонки текста). Высота фиксированная: `min(30vh, 320px)`, на узких экранах `min(25vh, 200px)`; `object-fit: cover`, по центру; без скруглений. Заголовок, даты и теги остаются под ним в колонке текста. У `` сохраняются `width`/`height`. Без обложки баннера нет. - Расчётное время чтения. - Счётчик просмотров. - Лайки и дизлайки. @@ -617,3 +617,4 @@ Garage не шлёт уведомления об изменениях в бак - 2026-11-04 — Метрики, голоса, лимиты: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). Счётчики за всё время по посту и метке (дневные тренды Grafana считает через `increase()`), дневные просмотры — только для «Популярных» (удаляются старше `POPULAR_DAYS`). Поисковые запросы: живые запросы `search.js` (`X-Search: live`) не сохраняются, сохраняется устоявшийся запрос; TTL 90 дней, не больше 10 000 строк, топ-20 в `/metrics`, обновляется раз в 5 минут. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, задача v2 с SHA-256, cost 1, счётчик `uint32` до `ALTCHA_MAX_NUMBER`, подпись ключа; свой маленький решатель `static/altcha.js` вместо виджета; ключи подписи выводятся из `ALTCHA_HMAC_KEY`; карта повторов ограничена 100 000. Beacon: дельта при каждом скрытии и `pagehide`, отправка через `fetch(…, {keepalive: true})` (тело JSON), в каждой — случайный id просмотра страницы; сервер суммирует время по просмотру и ограничивает сумму 4 ч; время на странице — среднее по просмотрам со временем чтения. Просмотры и глубина чтения: один раз на посетителя, пост и день; суточные хеши только в памяти (не больше 200 000), `ip_prefix` — адрес IPv4 или `/64` IPv6. Источник `internal` для переходов с самого сайта. Внешние домены — только из ссылок поста. `/api/altcha` и `/api/vote` делят `RATE_LIMIT_VOTE`; лимитер помнит не больше 10 000 клиентов. Cookie голоса хранится как SHA-256. «Популярные» после «Новых» на первой странице. `/metrics` в текстовом формате Prometheus, написан на стандартной библиотеке. - 2026-11-04 — Grafana, Caddy, README: дашборд `grafana/hyperhack.json` для источника данных типа Prometheus на VictoriaMetrics (использует функцию VictoriaMetrics `sort_by_label_numeric`); дневной столбец покрывает день, который начинается в его точке, поэтому сегодняшний день виден. `Caddyfile.example` для Caddy `v2.11.7` с `caddy-ratelimit` `v0.1.0` (1200 запросов в минуту на IP клиента; `/static/*` и `/assets/*` не учитываются) и `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` и `/reindex` (и их подпути) на публичном домене отвечают `404`; сжатие `zstd`/`gzip`. README: если Caddy на хосте, `TRUSTED_PROXIES` — шлюз сети Compose. Полный README на английском и русском. - 2026-11-04 — Продакшен Compose: реестр образов GHCR (подтверждено). `docker-compose.yml` только скачивает выпущенный образ, без `build:`, закреплён последний релиз (`0.1.0`; тег образа — git-тег без `v`). Локальная сборка в `compose.dev.yml`, только через `-f`; без `docker-compose.override.yml`. Быстрый старт в README использует скачанный `docker-compose.yml` без правок, README описывает обновление и откат. +- 2026-11-04 — Обложка поста — баннер на всю ширину между верхней панелью и колонкой текста: высота `min(30vh, 320px)` (`min(25vh, 200px)` на экранах до 30rem), `object-fit: cover` по центру, без скруглений, `width`/`height` сохраняются. Выводится из блока `banner` в шаблоне страницы, вне `
`. Посты без обложки не меняются. diff --git a/internal/server/seo_test.go b/internal/server/seo_test.go index a721622..f3a6ba1 100644 --- a/internal/server/seo_test.go +++ b/internal/server/seo_test.go @@ -65,8 +65,17 @@ func TestPostHead(t *testing.T) { t.Errorf("JSON-LD = %v", ld) } - // No cover: DEFAULT_OG_IMAGE. - mustContain(t, s.get("/posts/zametka").Body.String(), ``, `"), strings.Index(body, `
`); !(h < c && c < m) { + t.Errorf("cover at %d, want between top bar (%d) and main (%d)", c, h, m) + } + + // No cover: DEFAULT_OG_IMAGE, no banner. + plain := s.get("/posts/zametka").Body.String() + mustContain(t, plain, ``, ` header h1 { margin: 0; font-size: clamp(1.875rem, 4vw + 1rem, 2.625rem); line-height: 1.15; font-weight: 700; letter-spacing: -0.025em; } .post > header .meta { margin-top: 0.75rem; } -.cover { display: block; width: 100%; margin: 2rem 0 0; border-radius: 12px; } +/* Banner between the top bar and main, full page width. The fixed height keeps the page from jumping. */ +.cover { display: block; width: 100%; height: min(30vh, 320px); object-fit: cover; object-position: center; background: var(--surface); } .prose { position: relative; margin-top: 2rem; overflow-wrap: break-word; } /* Read-depth markers for the post script (IntersectionObserver). */ .depth { position: absolute; left: 0; width: 1px; height: 1px; pointer-events: none; } @@ -204,6 +205,7 @@ code, pre { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, @media (max-width: 30rem) { .topbar { padding: 0.75rem 1rem; } main { padding: 1.75rem 1rem 3rem; } + .cover { height: min(25vh, 200px); } .brand span { font-size: 1rem; } .search input { flex-basis: 100%; } .search select { flex: 1; max-width: none; } diff --git a/internal/server/web/templates/layout.html b/internal/server/web/templates/layout.html index cb603d7..1ba7375 100644 --- a/internal/server/web/templates/layout.html +++ b/internal/server/web/templates/layout.html @@ -62,6 +62,7 @@ {{- end}} +{{- block "banner" .}}{{end}}
{{block "main" .}}{{end}}
diff --git a/internal/server/web/templates/post.html b/internal/server/web/templates/post.html index 0151337..b6125a3 100644 --- a/internal/server/web/templates/post.html +++ b/internal/server/web/templates/post.html @@ -1,4 +1,7 @@ {{define "title"}}{{.Post.Title}} — {{.Site.Name}}{{end}} +{{define "banner"}}{{with .Post}}{{if .Cover}} + +{{- end}}{{end}}{{end}} {{define "main"}} {{- $d := .}} {{- with .Post}} @@ -10,9 +13,6 @@

{{.Title}}

· {{t "min_read" "n" (minutes .Words)}} · {{t "views" "n" $d.Views}}

{{- template "tags" .Tags}} - {{- if .Cover}} - - {{- end}}
{{.HTML}}