diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ed95d26..aef4148 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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' diff --git a/README.md b/README.md index f4a08eb..f707f70 100644 --- a/README.md +++ b/README.md @@ -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, песочница, паркур). diff --git a/deploy/nginx-site.conf b/deploy/nginx-site.conf index dfe8882..4b9d9cb 100644 --- a/deploy/nginx-site.conf +++ b/deploy/nginx-site.conf @@ -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; } diff --git a/deploy/setup-server.sh b/deploy/setup-server.sh index 734017a..c549f0d 100755 --- a/deploy/setup-server.sh +++ b/deploy/setup-server.sh @@ -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 </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 diff --git a/deploy/vizitka-api.service b/deploy/vizitka-api.service new file mode 100644 index 0000000..58a6d74 --- /dev/null +++ b/deploy/vizitka-api.service @@ -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 diff --git a/server/server.mjs b/server/server.mjs new file mode 100644 index 0000000..6260c55 --- /dev/null +++ b/server/server.mjs @@ -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}`)); diff --git a/tools/gen_arch.py b/tools/gen_arch.py index 753f296..eddcc1a 100644 --- a/tools/gen_arch.py +++ b/tools/gen_arch.py @@ -18,13 +18,13 @@ def head(title, desc, og, depth=1): - + - + @@ -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 '\n\n\n' for u in ['/', '/arch/'] + [f"/arch/{c['slug']}/" for c in CASES]: - if f'https://example.com{u}' not in sm: - sm = sm.replace('', f' https://example.com{u}\n') + if f'https://me.ibrash.duckdns.org{u}' not in sm: + sm = sm.replace('', f' https://me.ibrash.duckdns.org{u}\n') open(sm_path, 'w').write(sm) print('arch pages:', [c['slug'] for c in CASES]) diff --git a/tools/gen_projects.py b/tools/gen_projects.py index fb0a5a3..1bccc0b 100644 --- a/tools/gen_projects.py +++ b/tools/gen_projects.py @@ -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'https://example.com{u}' not in sm: - sm = sm.replace('', f' https://example.com{u}\n') + if f'https://me.ibrash.duckdns.org{u}' not in sm: + sm = sm.replace('', f' https://me.ibrash.duckdns.org{u}\n') open(sm_path, 'w').write(sm) print('projects pages: ok') diff --git a/web/arch/exhibition-pavilion/index.html b/web/arch/exhibition-pavilion/index.html index d69fb79..6ffa9e1 100644 --- a/web/arch/exhibition-pavilion/index.html +++ b/web/arch/exhibition-pavilion/index.html @@ -10,13 +10,13 @@ - + - + diff --git a/web/arch/index.html b/web/arch/index.html index 00b37b0..a902f5e 100644 --- a/web/arch/index.html +++ b/web/arch/index.html @@ -10,13 +10,13 @@ - + - + diff --git a/web/arch/loft-complex/index.html b/web/arch/loft-complex/index.html index c2a0839..5986dfb 100644 --- a/web/arch/loft-complex/index.html +++ b/web/arch/loft-complex/index.html @@ -10,13 +10,13 @@ - + - + diff --git a/web/arch/quiet-courtyard/index.html b/web/arch/quiet-courtyard/index.html index 067dd3e..7f97897 100644 --- a/web/arch/quiet-courtyard/index.html +++ b/web/arch/quiet-courtyard/index.html @@ -10,13 +10,13 @@ - + - + diff --git a/web/css/style.css b/web/css/style.css index 6b46f6f..820dbda 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -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-контейнере ведущий пробел иначе схлопывается */ diff --git a/web/fixed/index.html b/web/fixed/index.html index 63df79d..99b97a1 100644 --- a/web/fixed/index.html +++ b/web/fixed/index.html @@ -11,7 +11,7 @@ - + diff --git a/web/index.html b/web/index.html index ac683d5..781784d 100644 --- a/web/index.html +++ b/web/index.html @@ -10,13 +10,13 @@ - + - + @@ -43,12 +43,17 @@

Давай общаться

Хочешь пообщаться? Пиши, отвечу в течение пары дней.

+
+ +

Уже помахали: …

+
+ diff --git a/web/js/wave.js b/web/js/wave.js new file mode 100644 index 0000000..64737da --- /dev/null +++ b/web/js/wave.js @@ -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); }); + }); +})(); diff --git a/web/projects/amans-world/index.html b/web/projects/amans-world/index.html index 8e396a0..5b6684a 100644 --- a/web/projects/amans-world/index.html +++ b/web/projects/amans-world/index.html @@ -10,13 +10,13 @@ - + - + diff --git a/web/projects/homelab/index.html b/web/projects/homelab/index.html index b164bd2..6c5e3e4 100644 --- a/web/projects/homelab/index.html +++ b/web/projects/homelab/index.html @@ -10,13 +10,13 @@ - + - + diff --git a/web/projects/index.html b/web/projects/index.html index b938e3c..8edb376 100644 --- a/web/projects/index.html +++ b/web/projects/index.html @@ -10,13 +10,13 @@ - + - + diff --git a/web/robots.txt b/web/robots.txt index 54a57c2..5fb71b3 100644 --- a/web/robots.txt +++ b/web/robots.txt @@ -1,4 +1,4 @@ User-agent: * Allow: / -Sitemap: https://example.com/sitemap.xml +Sitemap: https://me.ibrash.duckdns.org/sitemap.xml diff --git a/web/sitemap.xml b/web/sitemap.xml index 87663fb..77c72ee 100644 --- a/web/sitemap.xml +++ b/web/sitemap.xml @@ -1,11 +1,11 @@ - https://example.com/ - https://example.com/arch/ - https://example.com/projects/ - https://example.com/arch/quiet-courtyard/ - https://example.com/arch/exhibition-pavilion/ - https://example.com/arch/loft-complex/ - https://example.com/projects/amans-world/ - https://example.com/projects/homelab/ + https://me.ibrash.duckdns.org/ + https://me.ibrash.duckdns.org/arch/ + https://me.ibrash.duckdns.org/projects/ + https://me.ibrash.duckdns.org/arch/quiet-courtyard/ + https://me.ibrash.duckdns.org/arch/exhibition-pavilion/ + https://me.ibrash.duckdns.org/arch/loft-complex/ + https://me.ibrash.duckdns.org/projects/amans-world/ + https://me.ibrash.duckdns.org/projects/homelab/ diff --git a/web/training/index.html b/web/training/index.html index 4a83b43..19cacd4 100644 --- a/web/training/index.html +++ b/web/training/index.html @@ -11,7 +11,7 @@ - + diff --git a/web/work/index.html b/web/work/index.html index cd53c6a..485fae5 100644 --- a/web/work/index.html +++ b/web/work/index.html @@ -11,7 +11,7 @@ - +