Кнопка «Помахать» с общим счётчиком, домен, шапка
deploy / deploy (push) Successful in 4s

- Главная: тихая кнопка «Помахать» (web/js/wave.js), общий счётчик для всех посетителей
- server/server.mjs: мини-API (/api/waves, /api/wave), счётчик хранится вне репозитория
  (/var/lib/vizitka-site/waves.json), лимит раз в секунду с IP, проверка Origin
- deploy: systemd-сервис vizitka-api, проксирование /api в nginx, setup-server.sh ставит nodejs
- CI: после git pull перезапускает API
- Домен https://me.ibrash.duckdns.org вместо заглушки, исправлен пробел в шапке
- README и заметки обновлены

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 16:55:30 +05:00
co-authored by Claude Sonnet 5.5
parent 8de0a05848
commit 6ddece7017
23 changed files with 332 additions and 52 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
# CI: при пуше в main заходим на сервер по ssh и делаем git pull.
# CI: при пуше в main заходим на сервер по ssh, делаем git pull и перезапускаем API счётчика
# (счётчик лежит в /var/lib/vizitka-site вне репозитория и при этом не сбрасывается).
# Всё остальное уже настроено на сервере (nginx раздаёт /opt/vizitka-site/web, см. deploy/setup-server.sh).
# Нужен один секрет: DEPLOY_SSH_KEY (приватный ключ с доступом к серверу).
name: deploy
@@ -23,4 +24,4 @@ jobs:
# отпечаток ключа сервера закреплён: подмена сервера не пройдёт
echo "192.168.10.112 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHFLwAGOvgF5ZSBiZ9YRe78gT9HN1MeESQIaM6Aou/Ud" > "$HOME/.ssh/known_hosts"
ssh -i "$HOME/.ssh/id_deploy" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes \
root@192.168.10.112 'cd /opt/vizitka-site && git pull --ff-only && git log -1 --oneline'
root@192.168.10.112 'cd /opt/vizitka-site && git pull --ff-only && { systemctl restart vizitka-api || echo "сервис vizitka-api пока не настроен (deploy/setup-server.sh)"; } && git log -1 --oneline'
+20 -9
View File
@@ -5,7 +5,7 @@
- Репозиторий: `git@git.ibrash.duckdns.org:aibrash/vizitka-site.git` (Gitea 1.27), ветка `main`
- Сервер: `root@192.168.10.112` (Debian 12, имя хоста `visitka-site`): клон репозитория в `/opt/vizitka-site`, nginx отдаёт его папку `web/`, пока по HTTP внутри сети
- Публичного домена пока нет: в OG-тегах и `sitemap.xml` стоит заглушка `https://example.com` (как заменить: раздел 5)
- Домен: `https://me.ibrash.duckdns.org` (HTTPS терминирует отдельный прокси на openresty перед сервером). Он прописан в OG-тегах, `sitemap.xml` и `robots.txt`; как сменить: раздел 5
---
@@ -71,6 +71,7 @@ web/ то, что уходит на сервер (nginx root)
js/card.js заполняет главную из config.json
js/nav.js верхнее меню из config.pages (скрывает draft), подсветка текущего раздела
js/count.js числа «набегают» при появлении
js/wave.js кнопка «Помахать» со счётчиком на главной (говорит с /api)
arch/ список проектов + arch/<проект>/ (три страницы)
projects/ список + projects/amans-world/ + projects/homelab/
training/ work/ fixed/ ЧЕРНОВИКИ будущих страниц (draft, noindex), в меню не видны
@@ -79,8 +80,9 @@ web/ то, что уходит на сервер (nginx root)
fonts/ woff2 Onest и лицензия
404.html, favicon.svg, robots.txt, sitemap.xml
templates/page.html шаблон новой страницы
server/ server.mjs: мини-API счётчика «Помахать» (Node, без зависимостей)
tools/ gen_arch.py, gen_projects.py: генераторы страниц архитектуры и проектов
deploy/ nginx-site.conf, setup-server.sh (разовая настройка сервера)
deploy/ nginx-site.conf, vizitka-api.service, setup-server.sh (разовая настройка сервера)
.gitea/workflows/deploy.yml CI
```
@@ -88,6 +90,15 @@ deploy/ nginx-site.conf, setup-server.sh (разовая наст
Содержимое берётся из `web/config.json` (имя, `photo`, `status`, `tagline`, `facts`, `tags`, `cta`, `email`, `socials`). Если `photo` пустое, показываются инициалы. До загрузки конфига страница скрыта (`html.js:not(.ready) body[data-hide] .page`), чтобы вёрстка не прыгала; без JS всё видно сразу.
Хоумлаб на главной не упоминается **намеренно** (так просил владелец).
### Кнопка «Помахать» (интерактив на главной)
Тихая «призрачная» кнопка внизу карточки «Давай общаться»: прозрачная, тонкая рамка, цвет `--muted`, не конкурирует с ссылками. При клике ладонь машет (`@keyframes wave`, как в приветствии), число плавно докручивается, всплывает маленькое «+1». Счётчик общий для всех посетителей.
- Фронтенд: `web/js/wave.js` и блок `.wave-row` в `index.html`. Если API недоступно (например, при просмотре через `python3 -m http.server`), блок прячется (`visibility:hidden`, место зарезервировано, чтобы вёрстка не прыгала).
- Бэкенд: `server/server.mjs`, `GET /api/waves` и `POST /api/wave`. Лимит: не чаще раза в секунду с одного IP и не больше 300 в минуту на всех; POST принимается только с разрешённых сайтов (`ALLOWED_ORIGINS`); тело запроса игнорируется, от клиента данные не берутся.
- **Счётчик не сбрасывается:** хранится в `/var/lib/vizitka-site/waves.json` (создаётся systemd, вне репозитория), пишется атомарно, перед каждой записью предыдущее значение копируется в `waves.json.bak`, при остановке сервиса значение сохраняется. `git pull`, пуши и перезапуски сервера его не трогают. Не клади файл счётчика внутрь репозитория.
- Сервис `vizitka-api` (systemd, `DynamicUser`, песочница, `MemoryMax=96M`), настройки в `/etc/vizitka-api.env` (создаются один раз и потом не перезаписываются), nginx проксирует `/api/` на `127.0.0.1:8787` и передаёт IP посетителя (`X-Client-IP`).
- Локальная проверка: `DATA_FILE=/tmp/waves.json STATIC_DIR=web PORT=8791 node server/server.mjs` и открыть `http://localhost:8791`.
- Править текст кнопки и числа: прямо в `web/index.html` и `web/js/wave.js`.
### Меню и страницы
Меню рисует `js/nav.js` из `config.json` → `pages`. На главной оно появляется, только если есть опубликованные страницы. Внутренние страницы всегда показывают имя и пункты. На узких экранах фамилия в шапке прячется (чтобы влезли все пункты).
@@ -142,15 +153,15 @@ magick src.png -strip -resize 640x640\> -quality 74 имя-sm.webp
## 5. Деплой и CI
### Схема (простая)
`push в main` → Gitea Actions (`.gitea/workflows/deploy.yml`) заходит по ssh на `root@192.168.10.112` и делает `git pull` в `/opt/vizitka-site` → nginx раздаёт `/opt/vizitka-site/web`, перезапуск не нужен. Сборки нет: что лежит в `web/`, то и на сайте.
`push в main` → Gitea Actions (`.gitea/workflows/deploy.yml`) заходит по ssh на `root@192.168.10.112`, делает `git pull` в `/opt/vizitka-site` и перезапускает API счётчика (`systemctl restart vizitka-api`) → nginx раздаёт `/opt/vizitka-site/web`. Для статики перезапуск не нужен; сборки нет: что лежит в `web/`, то и на сайте.
Репозиторий публичный, поэтому сервер клонирует и обновляет его по HTTPS без ключей и паролей: `https://git.ibrash.duckdns.org/aibrash/vizitka-site.git`. Если репозиторий станет закрытым, на сервер придётся добавить deploy-ключ (в Gitea: репозиторий → Настройки → Deploy keys) или токен.
### Что настроено на сервере (сделано, `deploy/setup-server.sh`)
```bash
./deploy/setup-server.sh root@192.168.10.112 # ставит nginx и git, клонирует репозиторий в /opt/vizitka-site, подключает deploy/nginx-site.conf
./deploy/setup-server.sh root@192.168.10.112 # ставит nginx, git и nodejs, клонирует репозиторий в /opt/vizitka-site, включает сервис vizitka-api и подключает deploy/nginx-site.conf
```
Скрипт безопасно запускать повторно: если клон уже есть, он делает `git pull`, и заново применяет конфиг nginx (там `root /opt/vizitka-site/web`, сжатие, кэш, 404, строгий CSP, скрытые файлы закрыты).
Скрипт безопасно запускать повторно: если клон уже есть, он делает `git pull`, обновляет systemd-юнит и заново применяет конфиг nginx (`/etc/vizitka-api.env` не перезаписывается, счётчик не трогается) (там `root /opt/vizitka-site/web`, сжатие, кэш, 404, строгий CSP, скрытые файлы закрыты).
Если нужно поменять конфиг nginx: правь `deploy/nginx-site.conf` и снова запусти скрипт. Если `/opt/vizitka-site` уже был не репозиторием, скрипт откладывает его в `/opt/vizitka-site.old-<время>`, а не удаляет (одна такая папка там сейчас лежит, это старая выкладка через rsync, её можно удалить).
### Что нужно для работы CI
@@ -166,11 +177,11 @@ ssh root@192.168.10.112 'cd /opt/vizitka-site && git pull --ff-only'
### HTTPS и домен (позже)
Сейчас сервер отдаёт HTTP только внутри сети. Для публичного доступа лучше поставить перед ним обратный прокси (Caddy или nginx на отдельном хосте с сертификатом) или Cloudflare Tunnel, а сам сервер с сайтом не выставлять наружу напрямую рядом с остальными домашними устройствами.
Заглушка `https://example.com` лежит в OG-тегах всех `.html`, в `web/sitemap.xml` и `web/robots.txt`. Раньше домен подставлял скрипт выкладки, теперь выкладки-скрипта нет, поэтому когда выберешь домен, замени один раз и закоммить:
Домен `me.ibrash.duckdns.org` уже подставлен в OG-теги, `sitemap.xml`, `robots.txt` и в `tools/`. Если домен сменится, замени один раз и закоммить (и поправь `ORIGINS`/`ALLOWED_ORIGINS` для API: `/etc/vizitka-api.env`, потом `systemctl restart vizitka-api`):
```bash
grep -rlF 'https://example.com' web tools | xargs sed -i 's|https://example.com|https://ТВОЙ.ДОМЕН|g'
grep -rlF 'https://me.ibrash.duckdns.org' web tools | xargs sed -i 's|https://me.ibrash.duckdns.org|https://НОВЫЙ.ДОМЕН|g'
```
(в `tools/` она тоже есть, чтобы генераторы не вернули заглушку).
Реальный IP посетителя nginx берёт из `X-Forwarded-For` (`set_real_ip_from` для частных сетей в `deploy/nginx-site.conf`): если передний прокси поменяется и не будет присылать этот заголовок, лимит «раз в секунду» станет общим на всех, проверь по `access.log`.
---
@@ -205,7 +216,7 @@ python3 -m http.server 8787 --directory web # локальный просм
**Что осталось сделать (на момент написания).**
1. Убедиться, что в Gitea есть runner и CI срабатывает на пуш (секрет `DEPLOY_SSH_KEY` уже добавлен). Удалить с сервера `/opt/vizitka-site.old-*`.
2. Выбрать домен, включить HTTPS через прокси или туннель, заменить заглушку `example.com` (раздел 5).
2. Проверить, что кнопка «Помахать» в проде получает реальные IP посетителей (смотреть `/var/log/nginx/access.log`) и что счётчик переживает перезапуск (`systemctl restart vizitka-api`).
3. Реальные ники (в `config.json` у Telegram и Instagram стоят короткие ники и их handle могут быть временными), проверка текстов про игру и про хоумлаб.
4. Страницы Тренировки, Работа, Фикс (черновики уже лежат в `web/`).
5. Фото сервера для хоумлаба вместо иллюстрации, больше кадров игры (Backrooms, песочница, паркур).
+18
View File
@@ -7,6 +7,14 @@ server {
root /opt/vizitka-site/web;
index index.html;
# реальный IP посетителя из X-Forwarded-For, если запрос пришёл от прокси из локальной сети (для лимита в API)
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 127.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# сжатие
gzip on;
gzip_vary on;
@@ -20,6 +28,16 @@ server {
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# API кнопки «Помахать» (Node, systemd-сервис vizitka-api), счётчик хранится вне репозитория
location /api/ {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Client-IP $remote_addr;
proxy_read_timeout 10s;
client_max_body_size 1k;
}
location / {
try_files $uri $uri/ =404;
}
+24 -6
View File
@@ -1,19 +1,22 @@
#!/usr/bin/env bash
# Разовая подготовка сервера: nginx, git, клон репозитория в /opt/vizitka-site. Безопасно запускать повторно.
# Использование: ./deploy/setup-server.sh root@192.168.10.112
# Разовая подготовка сервера: nginx, git, nodejs, клон репозитория в /opt/vizitka-site, сервис API счётчика.
# Безопасно запускать повторно. Использование: ./deploy/setup-server.sh root@192.168.10.112
# Обновления потом приходят через `git pull` (его делает CI, см. .gitea/workflows/deploy.yml).
set -euo pipefail
TARGET="${1:?укажите сервер: ./deploy/setup-server.sh root@192.168.10.112}"
REPO_URL="${REPO_URL:-https://git.ibrash.duckdns.org/aibrash/vizitka-site.git}" # репозиторий публичный: ключи не нужны
# откуда разрешены POST к API (домен сайта и адрес в локальной сети)
ORIGINS="${ORIGINS:-https://me.ibrash.duckdns.org,http://me.ibrash.duckdns.org,http://192.168.10.112}"
HERE="$(cd "$(dirname "$0")" && pwd)"
scp -q "$HERE/nginx-site.conf" "$TARGET:/tmp/vizitka-nginx.conf"
ssh "$TARGET" "REPO_URL='$REPO_URL' bash -s" <<'REMOTE'
scp -q "$HERE/vizitka-api.service" "$TARGET:/tmp/vizitka-api.service"
ssh "$TARGET" "REPO_URL='$REPO_URL' ORIGINS='$ORIGINS' bash -s" <<'REMOTE'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if ! command -v nginx >/dev/null || ! command -v git >/dev/null; then
if ! command -v nginx >/dev/null || ! command -v git >/dev/null || ! command -v node >/dev/null; then
apt-get update -qq
apt-get install -y -qq nginx git >/dev/null
apt-get install -y -qq nginx git nodejs >/dev/null
fi
DIR=/opt/vizitka-site
@@ -24,11 +27,26 @@ else
git clone "$REPO_URL" "$DIR"
fi
# сервис API: настройки лежат вне репозитория; существующий файл не перезаписываем
if [ ! -f /etc/vizitka-api.env ]; then
cat > /etc/vizitka-api.env <<ENV
PORT=8787
HOST=127.0.0.1
DATA_FILE=/var/lib/vizitka-site/waves.json
ALLOWED_ORIGINS=$ORIGINS
TRUST_PROXY=1
ENV
fi
install -m 644 /tmp/vizitka-api.service /etc/systemd/system/vizitka-api.service
systemctl daemon-reload
systemctl enable vizitka-api >/dev/null 2>&1
systemctl restart vizitka-api
install -m 644 /tmp/vizitka-nginx.conf /etc/nginx/sites-available/vizitka-site
ln -sf /etc/nginx/sites-available/vizitka-site /etc/nginx/sites-enabled/vizitka-site
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl enable --now nginx >/dev/null 2>&1
systemctl reload nginx
echo "готово: nginx $(nginx -v 2>&1 | cut -d/ -f2), сайт: $DIR/web (коммит $(git -C "$DIR" rev-parse --short HEAD))"
echo "готово: nginx $(nginx -v 2>&1 | cut -d/ -f2), node $(node --version), сайт $DIR/web (коммит $(git -C "$DIR" rev-parse --short HEAD)), API: $(systemctl is-active vizitka-api)"
REMOTE
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=vizitka-site API (счётчик «Помахать»)
After=network.target
[Service]
Type=simple
# отдельный временный пользователь без прав; каталог /var/lib/vizitka-site создаётся сам и переживает пуши и перезапуски
DynamicUser=yes
StateDirectory=vizitka-site
EnvironmentFile=/etc/vizitka-api.env
ExecStart=/usr/bin/node /opt/vizitka-site/server/server.mjs
Restart=on-failure
RestartSec=3
# песочница
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryMax=96M
[Install]
WantedBy=multi-user.target
+124
View File
@@ -0,0 +1,124 @@
// Мини-API для кнопки «Помахать» на главной: общий счётчик для всех посетителей.
// GET /api/waves -> { "count": N }
// POST /api/wave -> { "count": N+1 } (не чаще раза в секунду с одного IP)
// Без зависимостей, Node 18+. Счётчик хранится в файле ВНЕ репозитория (по умолчанию /var/lib/vizitka-site/waves.json),
// поэтому git pull, пуши и перезапуски его не сбрасывают.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || '127.0.0.1';
const DATA_FILE = process.env.DATA_FILE || path.join(HERE, 'data', 'waves.json');
// Откуда разрешены POST (через запятую), например: https://me.ibrash.duckdns.org,http://192.168.10.112
const ORIGINS = (process.env.ALLOWED_ORIGINS || '').split(',').map((s) => s.trim().replace(/\/+$/, '')).filter(Boolean);
// 1, если API стоит за nginx на этой же машине: тогда IP посетителя берём из X-Client-IP
const TRUST_PROXY = process.env.TRUST_PROXY === '1';
// Только для локальной проверки: раздавать статику из этого каталога
const STATIC_DIR = process.env.STATIC_DIR ? path.resolve(process.env.STATIC_DIR) : '';
/* ---------- счётчик: хранение ---------- */
const readCount = (file) => {
try {
const v = JSON.parse(fs.readFileSync(file, 'utf8')).count;
return Number.isSafeInteger(v) && v >= 0 ? v : null;
} catch { return null; }
};
// основной файл, при порче берём резервную копию
let count = readCount(DATA_FILE) ?? readCount(DATA_FILE + '.bak') ?? 0;
let dirty = false, timer = null;
function save() {
if (!dirty) return;
dirty = false;
try {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
if (fs.existsSync(DATA_FILE)) fs.copyFileSync(DATA_FILE, DATA_FILE + '.bak'); // предыдущее значение на всякий случай
const tmp = DATA_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ count }));
fs.renameSync(tmp, DATA_FILE); // атомарно: файл не побьётся при падении
} catch (e) { console.error('не удалось сохранить счётчик:', e.message); dirty = true; }
}
const scheduleSave = () => { dirty = true; if (!timer) timer = setTimeout(() => { timer = null; save(); }, 1000); };
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { save(); process.exit(0); });
process.on('exit', save);
/* ---------- лимиты ---------- */
const lastHit = new Map(); // ip -> время последнего махания
let minuteStart = Date.now(), minuteHits = 0;
const MAX_PER_MINUTE = 300; // общий предел на всех: защита от флуда с разных адресов
function allowWave(ip) {
const now = Date.now();
if (now - minuteStart > 60_000) { minuteStart = now; minuteHits = 0; }
if (minuteHits >= MAX_PER_MINUTE) return false;
const last = lastHit.get(ip);
if (last !== undefined && now - last < 1000) return false;
if (lastHit.size > 20000) lastHit.clear();
lastHit.set(ip, now); minuteHits++;
return true;
}
setInterval(() => { const cut = Date.now() - 60_000; for (const [k, t] of lastHit) if (t < cut) lastHit.delete(k); }, 30_000).unref();
function clientIp(req) {
const remote = req.socket.remoteAddress || '';
const loop = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
if (TRUST_PROXY && loop) {
const h = req.headers['x-client-ip'];
if (typeof h === 'string' && h.length < 64) return h.trim();
}
return remote;
}
/* ---------- HTTP ---------- */
function send(res, code, body, extra = {}) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...extra });
res.end(JSON.stringify(body));
}
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml' };
function serveStatic(res, pathname) {
let rel; try { rel = decodeURIComponent(pathname); } catch { return send(res, 400, { error: 'bad_request' }); }
let file = path.join(STATIC_DIR, path.normalize(rel));
if (file !== STATIC_DIR && !file.startsWith(STATIC_DIR + path.sep)) return send(res, 403, { error: 'forbidden' });
try { if (fs.statSync(file).isDirectory()) file = path.join(file, 'index.html'); } catch { /* дальше 404 */ }
fs.readFile(file, (err, buf) => {
if (err) return fs.readFile(path.join(STATIC_DIR, '404.html'), (_, nf) => { res.writeHead(404, { 'Content-Type': MIME['.html'] }); res.end(nf || 'not found'); });
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'X-Content-Type-Options': 'nosniff' }); res.end(buf);
});
}
const server = http.createServer((req, res) => {
let url; try { url = new URL(req.url, 'http://localhost'); } catch { return send(res, 400, { error: 'bad_request' }); }
const { pathname } = url;
if (!pathname.startsWith('/api/')) {
if (STATIC_DIR && (req.method === 'GET' || req.method === 'HEAD')) return serveStatic(res, pathname);
return send(res, 404, { error: 'not_found' });
}
if (pathname === '/api/waves' && req.method === 'GET') return send(res, 200, { count });
if (pathname === '/api/wave' && req.method === 'POST') {
const origin = req.headers.origin;
if (ORIGINS.length && origin && !ORIGINS.includes(origin)) return send(res, 403, { error: 'forbidden_origin' });
// тело игнорируем целиком: от клиента не принимаем никаких данных; читаем с лимитом, чтобы не копить
let size = 0;
req.on('data', (c) => { size += c.length; if (size > 1024) req.destroy(); });
req.on('error', () => {});
req.on('end', () => {
if (!allowWave(clientIp(req))) return send(res, 429, { error: 'too_many_requests' }, { 'Retry-After': '1' });
count += 1; scheduleSave();
send(res, 200, { count });
});
return;
}
send(res, 404, { error: 'not_found' });
});
server.headersTimeout = 10_000;
server.requestTimeout = 10_000;
server.listen(PORT, HOST, () => console.log(`api: http://${HOST}:${PORT}, счётчик ${count}, файл ${DATA_FILE}`));
+4 -4
View File
@@ -18,13 +18,13 @@ def head(title, desc, og, depth=1):
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="{E(title)}: Аман Ибраш">
<meta property="og:description" content="{E(desc)}">
<meta property="og:image" content="https://example.com/og/{og}">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/{og}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{E(title)}: Аман Ибраш">
<meta name="twitter:description" content="{E(desc)}">
<meta name="twitter:image" content="https://example.com/og/{og}">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/{og}">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
@@ -203,7 +203,7 @@ open(W + 'arch/index.html', 'w').write(idx)
sm_path = W + 'sitemap.xml'
sm = open(sm_path).read() if os.path.exists(sm_path) else '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n</urlset>\n'
for u in ['/', '/arch/'] + [f"/arch/{c['slug']}/" for c in CASES]:
if f'<loc>https://example.com{u}</loc>' not in sm:
sm = sm.replace('</urlset>', f' <url><loc>https://example.com{u}</loc></url>\n</urlset>')
if f'<loc>https://me.ibrash.duckdns.org{u}</loc>' not in sm:
sm = sm.replace('</urlset>', f' <url><loc>https://me.ibrash.duckdns.org{u}</loc></url>\n</urlset>')
open(sm_path, 'w').write(sm)
print('arch pages:', [c['slug'] for c in CASES])
+2 -2
View File
@@ -169,7 +169,7 @@ open(W + 'projects/homelab/index.html', 'w').write(hl)
sm_path = W + 'sitemap.xml'
sm = open(sm_path).read()
for u in ['/projects/', '/projects/amans-world/', '/projects/homelab/']:
if f'<loc>https://example.com{u}</loc>' not in sm:
sm = sm.replace('</urlset>', f' <url><loc>https://example.com{u}</loc></url>\n</urlset>')
if f'<loc>https://me.ibrash.duckdns.org{u}</loc>' not in sm:
sm = sm.replace('</urlset>', f' <url><loc>https://me.ibrash.duckdns.org{u}</loc></url>\n</urlset>')
open(sm_path, 'w').write(sm)
print('projects pages: ok')
+2 -2
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Выставочный павильон: Аман Ибраш">
<meta property="og:description" content="Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.">
<meta property="og:image" content="https://example.com/og/arch-exhibition-pavilion.jpg">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch-exhibition-pavilion.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Выставочный павильон: Аман Ибраш">
<meta name="twitter:description" content="Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.">
<meta name="twitter:image" content="https://example.com/og/arch-exhibition-pavilion.jpg">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch-exhibition-pavilion.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
+2 -2
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Архитектура: Аман Ибраш">
<meta property="og:description" content="Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.">
<meta property="og:image" content="https://example.com/og/arch.jpg">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Архитектура: Аман Ибраш">
<meta name="twitter:description" content="Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.">
<meta name="twitter:image" content="https://example.com/og/arch.jpg">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
+2 -2
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Жилой комплекс «Лофт»: Аман Ибраш">
<meta property="og:description" content="Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.">
<meta property="og:image" content="https://example.com/og/arch-loft-complex.jpg">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch-loft-complex.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Жилой комплекс «Лофт»: Аман Ибраш">
<meta name="twitter:description" content="Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.">
<meta name="twitter:image" content="https://example.com/og/arch-loft-complex.jpg">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch-loft-complex.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
+2 -2
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Тихий двор: Аман Ибраш">
<meta property="og:description" content="Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.">
<meta property="og:image" content="https://example.com/og/arch-quiet-courtyard.jpg">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch-quiet-courtyard.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Тихий двор: Аман Ибраш">
<meta name="twitter:description" content="Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.">
<meta name="twitter:image" content="https://example.com/og/arch-quiet-courtyard.jpg">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch-quiet-courtyard.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
+16
View File
@@ -305,3 +305,19 @@ a.tile:hover { transform: translateY(-4px); box-shadow: 0 16px 36px rgba(18, 26,
/* последняя плитка: «список открытый» */
.tile--more { background: transparent; box-shadow: none; border: 2px dashed var(--line); align-content: center; }
.tile--more h3 { color: var(--accent); }
/* ===== «Помахать»: тихая кнопка со счётчиком (не выделяется, повторяет приветствие 👋) ===== */
.wave-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; margin-top: .3rem; padding-top: .9rem; border-top: 1px dashed var(--line); min-height: 3.9rem; }
.wave-row.off { visibility: hidden; }
.wave-btn { position: relative; display: inline-flex; align-items: center; gap: .4rem; min-height: 2.75rem; padding: 0 1rem; border: 1px solid var(--line); border-radius: 99px; background: transparent; color: var(--muted); font: 600 .92rem var(--font); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color .2s, border-color .2s, background .2s, transform .1s; }
.wave-btn:hover { color: var(--ink); border-color: var(--accent); background: var(--card); }
.wave-btn:active { transform: scale(.96); }
.wave-btn[disabled] { cursor: default; }
.wave-em { display: inline-block; transform-origin: 70% 70%; }
.wave-btn.waving .wave-em { animation: wave 0.9s; }
.wave-count { font-size: .88rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.wave-count b { color: var(--ink); font-weight: 700; }
.wave-plus { position: absolute; right: .6rem; top: -.2rem; font-size: .85rem; font-weight: 700; color: var(--accent); pointer-events: none; animation: wave-plus .9s ease-out forwards; }
@keyframes wave-plus { from { opacity: 0; transform: translateY(.3rem); } 25% { opacity: 1; } to { opacity: 0; transform: translateY(-1.5rem); } }
@media (prefers-reduced-motion: no-preference) { .wave-row { animation-delay: .85s; } }
.topnav .brand-rest { white-space: pre; } /* в flex-контейнере ведущий пробел иначе схлопывается */
+1 -1
View File
@@ -11,7 +11,7 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Фикс: Аман Ибраш">
<meta property="og:description" content="Тут будет про фикс: байк, маршруты и километры.">
<meta property="og:image" content="https://example.com/og/home.png">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
+8 -3
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Аман Ибраш: давай общаться">
<meta property="og:description" content="Тренировки, работа и фикс. Пиши, пообщаемся.">
<meta property="og:image" content="https://example.com/og/home.png">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Аман Ибраш: давай общаться">
<meta name="twitter:description" content="Тренировки, работа и фикс. Пиши, пообщаемся.">
<meta name="twitter:image" content="https://example.com/og/home.png">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/home.png">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
@@ -43,12 +43,17 @@
<h2 id="h-talk">Давай общаться</h2>
<p class="cta" id="cta">Хочешь пообщаться? Пиши, отвечу в течение пары дней.</p>
<ul class="links" id="links">
<li><a class="link link--mail" id="mail" href="mailto:hello@example.com"><span class="ic" aria-hidden="true">@</span><span class="lk">Почта</span><span class="lh">hello@example.com</span></a></li>
<li><a class="link link--mail" id="mail" href="mailto:amnibrash@gmail.com"><span class="ic" aria-hidden="true">@</span><span class="lk">Почта</span><span class="lh">amnibrash@gmail.com</span></a></li>
</ul>
<div class="wave-row" id="wave-row">
<button class="wave-btn" id="wave-btn" type="button"><span class="wave-em" aria-hidden="true">👋</span> Помахать</button>
<p class="wave-count" id="wave-count" aria-live="polite">Уже помахали: <b id="wave-n">…</b></p>
</div>
</section>
</main>
<script src="/js/nav.js" defer></script>
<script src="/js/card.js" defer></script>
<script src="/js/wave.js" defer></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
// Тихая кнопка «Помахать»: общий счётчик для всех посетителей хранится на сервере (/api/waves, /api/wave).
// Если API недоступен (например, сайт открыт без бэкенда), блок просто прячется, остальная страница не страдает.
(function () {
var row = document.getElementById('wave-row');
var btn = document.getElementById('wave-btn');
var num = document.getElementById('wave-n');
var line = document.getElementById('wave-count');
if (!row || !btn || !num) return;
var count = null, busy = false, msgTimer = null;
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var fmt = function (n) { return n.toLocaleString('ru-RU'); };
// число плавно «докручивается» до нового значения
function setCount(n) {
var from = count === null ? n : count; count = n;
if (reduce || from === n) { num.textContent = fmt(n); return; }
var t0 = performance.now();
(function step(now) {
var k = Math.min(1, (now - t0) / 350);
num.textContent = fmt(Math.round(from + (n - from) * k));
if (k < 1) requestAnimationFrame(step);
})(t0);
}
function say(text) { // короткое сообщение вместо счётчика
clearTimeout(msgTimer);
line.textContent = text;
msgTimer = setTimeout(function () { line.textContent = 'Уже помахали: '; line.appendChild(num); }, 2200);
}
function plusOne() { // маленькое «+1» уплывает вверх
if (reduce) return;
var s = document.createElement('span');
s.className = 'wave-plus'; s.textContent = '+1'; s.setAttribute('aria-hidden', 'true');
btn.appendChild(s);
s.addEventListener('animationend', function () { s.remove(); });
}
fetch('/api/waves').then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (d) { setCount(d.count); })
.catch(function () { row.classList.add('off'); });
btn.addEventListener('click', function () {
if (busy || count === null) return;
busy = true; btn.disabled = true;
var prev = count;
setCount(count + 1);
btn.classList.remove('waving'); void btn.offsetWidth; btn.classList.add('waving');
plusOne();
fetch('/api/wave', { method: 'POST' }).then(function (r) {
if (r.status === 429) { say('Не так быстро, раз в секунду'); throw 0; }
if (!r.ok) throw new Error(r.status);
return r.json();
}).then(function (d) { setCount(d.count); })
.catch(function (e) { count = prev; num.textContent = fmt(prev); if (e !== 0) say('Не засчиталось, попробуй ещё раз'); })
.then(function () { setTimeout(function () { busy = false; btn.disabled = false; }, 1000); });
});
})();
+2 -2
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Aman&#x27;s World: Аман Ибраш">
<meta property="og:description" content="Aman&#x27;s World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only.">
<meta property="og:image" content="https://example.com/og/projects-amans-world.jpg">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/projects-amans-world.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Aman&#x27;s World: Аман Ибраш">
<meta name="twitter:description" content="Aman&#x27;s World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only.">
<meta name="twitter:image" content="https://example.com/og/projects-amans-world.jpg">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/projects-amans-world.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
+2 -2
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Хоумлаб: Аман Ибраш">
<meta property="og:description" content="Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.">
<meta property="og:image" content="https://example.com/og/projects-homelab.jpg">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/projects-homelab.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Хоумлаб: Аман Ибраш">
<meta name="twitter:description" content="Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.">
<meta name="twitter:image" content="https://example.com/og/projects-homelab.jpg">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/projects-homelab.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
+2 -2
View File
@@ -10,13 +10,13 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Проекты: Аман Ибраш">
<meta property="og:description" content="Мои проекты: Aman&#x27;s World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.">
<meta property="og:image" content="https://example.com/og/projects.jpg">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/projects.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Проекты: Аман Ибраш">
<meta name="twitter:description" content="Мои проекты: Aman&#x27;s World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.">
<meta name="twitter:image" content="https://example.com/og/projects.jpg">
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/projects.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
+1 -1
View File
@@ -1,4 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap.xml
Sitemap: https://me.ibrash.duckdns.org/sitemap.xml
+8 -8
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/</loc></url>
<url><loc>https://example.com/arch/</loc></url>
<url><loc>https://example.com/projects/</loc></url>
<url><loc>https://example.com/arch/quiet-courtyard/</loc></url>
<url><loc>https://example.com/arch/exhibition-pavilion/</loc></url>
<url><loc>https://example.com/arch/loft-complex/</loc></url>
<url><loc>https://example.com/projects/amans-world/</loc></url>
<url><loc>https://example.com/projects/homelab/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/arch/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/projects/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/arch/quiet-courtyard/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/arch/exhibition-pavilion/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/arch/loft-complex/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/projects/amans-world/</loc></url>
<url><loc>https://me.ibrash.duckdns.org/projects/homelab/</loc></url>
</urlset>
+1 -1
View File
@@ -11,7 +11,7 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Тренировки: Аман Ибраш">
<meta property="og:description" content="Тут будет про тренировки: что делаю, как часто и во сколько встаю.">
<meta property="og:image" content="https://example.com/og/home.png">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
+1 -1
View File
@@ -11,7 +11,7 @@
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="Работа: Аман Ибраш">
<meta property="og:description" content="Тут будет про работу: чем занимаюсь и куда расту.">
<meta property="og:image" content="https://example.com/og/home.png">
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">