CI: деплой через ssh и git pull, README со стилистикой и заметками
deploy / deploy (push) Successful in 9s
deploy / deploy (push) Successful in 9s
- .gitea/workflows/deploy.yml: при пуше в main заходит на сервер по ssh и делает git pull - deploy/: конфиг nginx (root /opt/vizitka-site/web) и скрипт разовой настройки сервера - tools/: генераторы страниц архитектуры и проектов (раньше лежали вне репозитория) - README: стилистика сайта, устройство, деплой, заметки для следующих сессий Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
# CI: при пуше в main заходим на сервер по ssh и делаем git pull.
|
||||||
|
# Всё остальное уже настроено на сервере (nginx раздаёт /opt/vizitka-site/web, см. deploy/setup-server.sh).
|
||||||
|
# Нужен один секрет: DEPLOY_SSH_KEY (приватный ключ с доступом к серверу).
|
||||||
|
name: deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Подключение к серверу и git pull
|
||||||
|
env:
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
command -v ssh >/dev/null || { apt-get update -qq && apt-get install -y -qq openssh-client; }
|
||||||
|
umask 077
|
||||||
|
install -d "$HOME/.ssh"
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > "$HOME/.ssh/id_deploy"
|
||||||
|
# отпечаток ключа сервера закреплён: подмена сервера не пройдёт
|
||||||
|
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'
|
||||||
@@ -1,68 +1,211 @@
|
|||||||
# Сайт-визитка
|
# Сайт-визитка Амана Ибраша
|
||||||
|
|
||||||
Одна страница на чистом HTML + CSS + JS, без сборки и бэкенда.
|
Личный сайт для закрепа в профилях соцсетей: человек заходит, видит, кто это, и может написать. Дальше есть разделы про архитектуру, проекты и хоумлаб.
|
||||||
|
Чистый HTML + CSS + JS, без сборщиков и фреймворков, без бэкенда. Статика раздаётся nginx.
|
||||||
|
|
||||||
|
- Репозиторий: `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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Стилистика сайта
|
||||||
|
|
||||||
|
Если добавляешь страницу или блок, держись этих правил: сайт должен остаться одним целым.
|
||||||
|
|
||||||
|
### Настроение
|
||||||
|
Светлый, воздушный, дружелюбный минимализм. Не «портфолио архитектора» и не «хакерский терминал» (оба варианта пробовали и от них отказались). Это визитка парня, который живёт «весело и всерьёз»: тренировки, работа, фикс, учёба. Картинок мало, но хорошие.
|
||||||
|
|
||||||
|
### Палитра (токены в `web/css/style.css`, блок `:root`)
|
||||||
|
| Токен | Цвет | Для чего |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#f4f6fb` | фон страницы |
|
||||||
|
| `--card` | `#ffffff` | карточки, плитки, подложки |
|
||||||
|
| `--ink` | `#121a2b` | основной текст и заголовки |
|
||||||
|
| `--muted` | `#55627a` | второстепенный текст (контраст 5,5:1 на фоне) |
|
||||||
|
| `--line` | `#e2e8f3` | рамки и разделители |
|
||||||
|
| `--accent` | `#3556ff` | кнопки, ссылки, активные пункты, цифры |
|
||||||
|
| `--accent-soft` | `#e8edff` | фон чипов и иконок |
|
||||||
|
| `--pop` | `#ffd84d` | «солнечный» жёлтый: аватар и пятно на фоне |
|
||||||
|
| `--good` | `#1fb865` | статус «на связи» |
|
||||||
|
|
||||||
|
Фон: два размытых цветных пятна (`.blob--a` жёлтое, `.blob--b` голубое), больше ничего. Тёмной темы нет, `color-scheme: light`.
|
||||||
|
|
||||||
|
### Шрифт
|
||||||
|
Только **Onest** (variable, `wght` 100..900), локально: `web/fonts/onest-{cyrillic,latin}-wght-normal.woff2`. Заголовки 700 и 800 с отрицательным `letter-spacing` (`-.02em` и меньше), текст 400. Никаких внешних шрифтов и CDN.
|
||||||
|
|
||||||
|
### Форма
|
||||||
|
Всё скруглённое: картинки и плитки 22 px, крупные карточки 24 до 28 px, ряды ссылок 18 px, чипы и кнопки капсулой (`99px`). Одна общая мягкая тень `--shadow`. Рамок почти нет, глубину даёт тень.
|
||||||
|
|
||||||
|
### Раскладка
|
||||||
|
- Mobile first. Проверять на ширине 360 px (и 320): горизонтального скролла быть не должно.
|
||||||
|
- Главная: на телефоне одна колонка, на ПК две (слева «обо мне», справа карточка «Давай общаться»).
|
||||||
|
- Внутренние страницы: контейнер `.page--wide` (до 62 rem), текст ограничен 44 rem ради читаемости.
|
||||||
|
- Тапаемые элементы не меньше 44 px.
|
||||||
|
|
||||||
|
### Анимации
|
||||||
|
Должны быть красивыми, но дешёвыми для слабого телефона: только `transform` и `opacity`.
|
||||||
|
- Главная: одна «сцена» входа (аватар «выпрыгивает», слова строки о себе появляются по очереди, теги появляются и потом чуть парят, фон дрейфует, «волна» у приветствия).
|
||||||
|
- Внутренние страницы: появление блоков по мере прокрутки (`animation-timeline: view()`, в браузерах без поддержки просто статика), картинки «оседают» (scale 1,12 → 1), числа в карточках проекта «набегают» от нуля (`web/js/count.js`), карточки и картинки приподнимаются или плавно увеличиваются при наведении, блик на кнопках ссылок, переходы между страницами (`@view-transition`).
|
||||||
|
- Первый экран страницы (обложка проекта, шапка) **не анимируется появлением**: иначе просядет LCP.
|
||||||
|
- Обязательно уважать `prefers-reduced-motion: reduce` (в CSS всё выключается одним правилом).
|
||||||
|
|
||||||
|
### Тон текстов
|
||||||
|
Живой, лёгкий, без пафоса и канцелярита. Русский язык. К посетителю на «ты» («Хочешь пообщаться? Пиши»), о себе от первого лица. Короткие предложения, лёгкая самоирония («Скучать некогда»), заголовки с заглавной только первого слова. Эмодзи только в тегах и фактах на главной. **Не выдумывать факты о владельце** (см. раздел 3): если чего-то не знаешь, пиши нейтрально или помечай `TODO`.
|
||||||
|
|
||||||
|
### Требования к качеству
|
||||||
|
- Lighthouse mobile: performance 90+ на каждой странице (сейчас от 93 до 100), доступность, практики и SEO по 100.
|
||||||
|
- Строгий CSP: **никаких inline-скриптов и inline-стилей**, никаких внешних ресурсов. Стили элементам из JS задаются через `element.style.setProperty` (это разрешено).
|
||||||
|
- Картинки: `width` и `height` обязательны (нет сдвигов), `loading="lazy"` для всего ниже первого экрана, у обложки `fetchpriority="high"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Как устроен сайт
|
||||||
|
|
||||||
```
|
```
|
||||||
web/index.html главная страница
|
web/ то, что уходит на сервер (nginx root)
|
||||||
web/arch/ список проектов, у каждого своя страница: web/arch/<проект>/
|
index.html главная (body[data-hide]: скрыта, пока не загрузится config.json)
|
||||||
web/projects/ список проектов, у каждого своя страница: web/projects/<проект>/ (сейчас Aman's World)
|
config.json ВСЁ содержимое главной и меню: имя, факты, теги, статус, ссылки, фото, список страниц
|
||||||
web/training|work|fixed/ заготовки будущих страниц (черновики)
|
css/style.css единственный файл стилей
|
||||||
templates/page.html шаблон новой страницы
|
js/boot.js в <head>: флаг .js, запрос config.json, window.siteReady
|
||||||
web/config.json всё содержимое: имя, статус, описание, теги, почта, соцсети, фото
|
js/card.js заполняет главную из config.json
|
||||||
web/css, js, fonts стили, скрипт, шрифты (локально)
|
js/nav.js верхнее меню из config.pages (скрывает draft), подсветка текущего раздела
|
||||||
web/og/home.png превью для соцсетей 1200x630
|
js/count.js числа «набегают» при появлении
|
||||||
web/404.html страница 404
|
arch/ список проектов + arch/<проект>/ (три страницы)
|
||||||
|
projects/ список + projects/amans-world/ + projects/homelab/
|
||||||
|
training/ work/ fixed/ ЧЕРНОВИКИ будущих страниц (draft, noindex), в меню не видны
|
||||||
|
img/ me.webp, arch/ (рендеры и чертежи), proj/ (скриншоты игры, иллюстрация хоумлаба)
|
||||||
|
og/ превью для соцсетей 1200x630 (png/jpg)
|
||||||
|
fonts/ woff2 Onest и лицензия
|
||||||
|
404.html, favicon.svg, robots.txt, sitemap.xml
|
||||||
|
templates/page.html шаблон новой страницы
|
||||||
|
tools/ gen_arch.py, gen_projects.py: генераторы страниц архитектуры и проектов
|
||||||
|
deploy/ nginx-site.conf, setup-server.sh (разовая настройка сервера)
|
||||||
|
.gitea/workflows/deploy.yml CI
|
||||||
```
|
```
|
||||||
|
|
||||||
## Что править
|
### Главная
|
||||||
Только `web/config.json`. Всё, где `username`, `example.com` и `TODO`, заменить своим.
|
Содержимое берётся из `web/config.json` (имя, `photo`, `status`, `tagline`, `facts`, `tags`, `cta`, `email`, `socials`). Если `photo` пустое, показываются инициалы. До загрузки конфига страница скрыта (`html.js:not(.ready) body[data-hide] .page`), чтобы вёрстка не прыгала; без JS всё видно сразу.
|
||||||
Скриншоты игры лежат в `web/img/proj/`. Картинки архитектуры лежат в `web/img/arch/` в трёх размерах: `имя.webp`, `имя-md.webp`, `имя-sm.webp` (телефонам отдаются лёгкие).
|
Хоумлаб на главной не упоминается **намеренно** (так просил владелец).
|
||||||
Новый проект в архитектуре: скопируйте любую страницу `web/arch/<проект>/index.html`, замените текст и картинки, добавьте карточку в `web/arch/index.html` и адрес в `sitemap.xml`.
|
|
||||||
Фото: положите файл в `web/img/` и впишите путь в `photo` (например `"/img/me.webp"`, квадрат от 192 px).
|
|
||||||
Если `photo` пустое, показываются инициалы.
|
|
||||||
|
|
||||||
## Страницы
|
### Меню и страницы
|
||||||
Опубликованы `web/arch/` и `web/projects/` (в обеих карточки ведут на страницы проектов). Заготовки (черновики) лежат в `web/training/` (Тренировки), `web/work/` (Работа), `web/fixed/` (Фикс). Сейчас это черновики: их нет в меню, а поисковикам они закрыты (`noindex`).
|
Меню рисует `js/nav.js` из `config.json` → `pages`. На главной оно появляется, только если есть опубликованные страницы. Внутренние страницы всегда показывают имя и пункты. На узких экранах фамилия в шапке прячется (чтобы влезли все пункты).
|
||||||
|
|
||||||
Опубликовать готовую страницу:
|
Новая страница:
|
||||||
1. Заполните текст в `web/<имя>/index.html` (абзацы, заголовки `h2`, ссылки оформятся сами).
|
1. Скопируй `templates/page.html` в `web/<имя>/index.html`, поменяй `title`, `description`, `og:*`, текст.
|
||||||
2. В `web/config.json` в `pages` у нужной записи уберите `"draft": true`: она появится в меню на всех страницах.
|
2. В `web/config.json` в `pages` добавь `{ "label": "Название", "url": "/<имя>/" }`.
|
||||||
3. В самой странице удалите строку `<meta name="robots" content="noindex">`.
|
3. Добавь адрес в `web/sitemap.xml`.
|
||||||
4. Добавьте адрес в `web/sitemap.xml`.
|
|
||||||
|
|
||||||
Новая страница с нуля: скопируйте `templates/page.html` в `web/<имя>/index.html`, правьте так же, а в `pages` добавьте `{ "label": "Название", "url": "/<имя>/" }`.
|
Черновики (`training`, `work`, `fixed`): в `pages` у них `"draft": true`, в самой странице есть `<meta name="robots" content="noindex">`. Чтобы опубликовать: убери `draft`, удали `noindex`, добавь в sitemap.
|
||||||
Пока в `pages` нет опубликованных страниц, на главной меню не показывается.
|
Страницу можно не вносить в меню (так сделан хоумлаб: он открывается из карточки на `/projects/`).
|
||||||
|
|
||||||
## Запуск локально
|
### Страницы архитектуры и проектов
|
||||||
|
Они **сгенерированы** скриптами, но результат обычный HTML, его можно править руками.
|
||||||
```bash
|
```bash
|
||||||
python3 -m http.server 8787 --directory web # открыть http://localhost:8787
|
python3 tools/gen_arch.py # /arch/ и /arch/<проект>/
|
||||||
|
python3 tools/gen_projects.py # /projects/, /projects/amans-world/, /projects/homelab/
|
||||||
```
|
```
|
||||||
|
Тексты, подписи и списки картинок лежат прямо в скриптах (`CASES` в `gen_arch.py`, секции в `gen_projects.py`). Скрипты идемпотентны: после запуска `git status` не должен показывать изменений, если ничего не правил. Дописывают недостающие адреса в `sitemap.xml`, остальное не трогают.
|
||||||
|
|
||||||
## Выкладка
|
### Картинки
|
||||||
Это просто статика: скопируйте папку `web/` на сервер и раздайте любым веб-сервером.
|
В `web/img/arch/` и `web/img/proj/` каждая картинка в трёх размерах: `имя.webp` (1200 до 1600 px по ширине), `имя-md.webp` (960), `имя-sm.webp` (640). Страницы отдают их через `srcset`, поэтому телефонам идут лёгкие. Рецепт:
|
||||||
Пример для Caddy (HTTPS получится сам):
|
|
||||||
```
|
|
||||||
my.domain {
|
|
||||||
root * /srv/visit-site/web
|
|
||||||
encode zstd gzip
|
|
||||||
file_server
|
|
||||||
handle_errors {
|
|
||||||
rewrite * /404.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
-Server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Потом подставьте домен в превью и поисковые файлы:
|
|
||||||
```bash
|
```bash
|
||||||
sed -i 's|https://example.com|https://my.domain|g' web/index.html web/robots.txt web/sitemap.xml
|
magick src.png -strip -resize 1400x1400\> -quality 78 имя.webp
|
||||||
|
magick src.png -strip -resize 960x960\> -quality 76 имя-md.webp
|
||||||
|
magick src.png -strip -resize 640x640\> -quality 74 имя-sm.webp
|
||||||
```
|
```
|
||||||
Если сервер стоит дома, лучше публиковать через Cloudflare Tunnel или держать его в отдельной сети, а не рядом с остальными устройствами.
|
Для чертежей (много мелкого текста) качество 80. Фото-подобные рендеры сжимаются хорошо (от 150 до 230 КБ при 1200 px).
|
||||||
|
|
||||||
## Проверено
|
---
|
||||||
Ширина 360 px без горизонтального скролла, вес страницы около 38 КБ вместе со шрифтами, Lighthouse (mobile и desktop) см. ниже в сообщении.
|
|
||||||
|
## 3. Что известно о владельце (источник правды для текстов)
|
||||||
|
|
||||||
|
Известно и можно использовать: Аман Ибраш, 18 лет, студент KazGASA в Алматы (архитектура), работает системным администратором и растёт в сторону DevOps, любит тренировки, фикс (фиксед гир) и работу. Дома хоумлаб (домашний сервер, на нём Nextcloud; адреса через DuckDNS; браузерная игра Aman's World; в планах переезд этого сайта на сервер и CI). Сам пользуется Fedora Linux (на своём компьютере, про ОС сервера ничего не известно, на сервере сейчас Debian 12).
|
||||||
|
Контакты, соцсети и e-mail лежат в `web/config.json`.
|
||||||
|
|
||||||
|
Не известно, не выдумывать: годы обучения, места работы, технологический стек DevOps, цифры тренировок и велосипеда, детали игры сверх написанного.
|
||||||
|
|
||||||
|
**Нельзя публиковать:** внутренние адреса и IP, имена хостов, данные работодателя, адрес и содержимое закрытого Nextcloud, имена заказчиков из папок с проектами. Страница хоумлаба называет только сервисы.
|
||||||
|
|
||||||
|
### Откуда материалы
|
||||||
|
- Архитектурные проекты из Nextcloud владельца: готовые рендеры «Тихого двора» лежат в `…/2026-09_ИЖД_Аман_Ибраш/03_Out/02_Рендеры/`, схемы и аксонометрии в `…/02_Work/05_Presentation/img/`; павильон и «Лофт» вырезаны из PDF-альбомов в `03_Out/01_PDF/`. Ссылка-доступ к Nextcloud не хранится в репозитории.
|
||||||
|
- Это студенческие работы. Про то, как сделаны рендеры дома, на сайте ничего не утверждается.
|
||||||
|
- Скриншоты игры: заставка и хаб сняты автоматически, два кадра гонок прислал владелец.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Игра Aman's World (что известно)
|
||||||
|
3D-игра на Godot 4 (веб-экспорт, около 40 МБ при первом запуске), адрес `https://grid.ibrash.duckdns.org/`. Работает **только на ПК**, можно играть клавиатурой и мышью или геймпадом (вроде Xbox). Неоновый хаб с 5 порталами (Sandbox, Backrooms, ещё один, Race, Race 2), ник до 16 символов, над гоночными порталами таблицы рекордов. Sector 3 Run (Race): 8 чекпоинтов; Ascend Only (Race 2): 13 чекпоинтов; перед стартом «R: готов (0/1)». По звукам и файлам: бег по стенам, прыжок от стены, рывок, скольжение, двойной прыжок, подтягивание на уступ, ускорители. Детали геймплея я только выводил из интерфейса и ассетов, владелец формулировки не проверял целиком.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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/`, то и на сайте.
|
||||||
|
|
||||||
|
Репозиторий публичный, поэтому сервер клонирует и обновляет его по 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
|
||||||
|
```
|
||||||
|
Скрипт безопасно запускать повторно: если клон уже есть, он делает `git pull`, и заново применяет конфиг nginx (там `root /opt/vizitka-site/web`, сжатие, кэш, 404, строгий CSP, скрытые файлы закрыты).
|
||||||
|
Если нужно поменять конфиг nginx: правь `deploy/nginx-site.conf` и снова запусти скрипт. Если `/opt/vizitka-site` уже был не репозиторием, скрипт откладывает его в `/opt/vizitka-site.old-<время>`, а не удаляет (одна такая папка там сейчас лежит, это старая выкладка через rsync, её можно удалить).
|
||||||
|
|
||||||
|
### Что нужно для работы CI
|
||||||
|
1. **Секрет** `DEPLOY_SSH_KEY` в Gitea (репозиторий → Настройки → Actions → Секреты): приватный ssh-ключ, у которого есть доступ к `root@192.168.10.112`. Владелец положил туда свой ключ (он уже добавлен). Ключ с правами root: безопаснее отдельный ключ только для CI (`ssh-keygen -t ed25519 -N "" -C gitea-ci`, публичную часть добавить в `/root/.ssh/authorized_keys` с опциями `no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty`).
|
||||||
|
2. **Runner** `act_runner` с меткой `ubuntu-latest`, который видит `192.168.10.112` по ssh (порт 22), то есть стоит в той же сети. Раннеру нужен только ssh-клиент (workflow доустановит `openssh-client`, если его нет), `actions/checkout` не используется. Есть ли у владельца runner, на момент написания неизвестно: без него workflow просто ждёт в очереди.
|
||||||
|
3. Отпечаток ключа сервера закреплён прямо в workflow (`known_hosts`). Если сервер переустановят, обновить: `ssh-keyscan -t ed25519 192.168.10.112` и подставить строку в `deploy.yml`.
|
||||||
|
|
||||||
|
### Быстро выложить вручную (без CI)
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.10.112 'cd /opt/vizitka-site && git pull --ff-only'
|
||||||
|
```
|
||||||
|
Проверка, что сайт жив: `curl -I http://192.168.10.112/` (ожидается 200). Откат: `ssh root@192.168.10.112 'cd /opt/vizitka-site && git reset --hard <коммит>'`.
|
||||||
|
|
||||||
|
### HTTPS и домен (позже)
|
||||||
|
Сейчас сервер отдаёт HTTP только внутри сети. Для публичного доступа лучше поставить перед ним обратный прокси (Caddy или nginx на отдельном хосте с сертификатом) или Cloudflare Tunnel, а сам сервер с сайтом не выставлять наружу напрямую рядом с остальными домашними устройствами.
|
||||||
|
Заглушка `https://example.com` лежит в OG-тегах всех `.html`, в `web/sitemap.xml` и `web/robots.txt`. Раньше домен подставлял скрипт выкладки, теперь выкладки-скрипта нет, поэтому когда выберешь домен, замени один раз и закоммить:
|
||||||
|
```bash
|
||||||
|
grep -rlF 'https://example.com' web tools | xargs sed -i 's|https://example.com|https://ТВОЙ.ДОМЕН|g'
|
||||||
|
```
|
||||||
|
(в `tools/` она тоже есть, чтобы генераторы не вернули заглушку).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Проверка перед выкладкой
|
||||||
|
```bash
|
||||||
|
python3 -m http.server 8787 --directory web # локальный просмотр: http://localhost:8787
|
||||||
|
```
|
||||||
|
- Страницы открываются, в консоли нет ошибок (в том числе нарушений CSP), нет горизонтального скролла на 360 px, нет битых картинок.
|
||||||
|
- Lighthouse mobile (через Chromium от Playwright: `CHROME_PATH=…/chrome npx lighthouse URL --only-categories=performance,accessibility,best-practices,seo`) на каждой странице: 90+.
|
||||||
|
- После `tools/gen_*.py` `git status` чистый.
|
||||||
|
- Быстрый осмотр после выкладки: `curl -I http://192.168.10.112/` и открыть сайт на телефоне (выкладка идёт через `git pull`, значит, на сервер попадает только закоммиченное и запушенное).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Заметки для следующих сессий (в том числе для ИИ-ассистента)
|
||||||
|
|
||||||
|
**Предпочтения владельца.** Минимализм (его слова: «верни минимализм, мне нравилось»), светлый и приятный дизайн, упор на мобильную версию, «красивые анимации», только реально хорошие фото. Просил упрощать, когда становилось сложно. Хочет, чтобы всё лишнее удаляли, но без потерь для нужного: однажды удалил папки со старыми версиями сам, а потом спрашивал «куда всё делось». Перед удалением чего-либо в проекте лучше спросить. Коммитить и пушить только по прямой просьбе.
|
||||||
|
|
||||||
|
**Окружение.**
|
||||||
|
- Оболочка zsh: незакавыченный глоб (`--include=*.html`) падает с «no matches found», берите в кавычки.
|
||||||
|
- `pkill -f "шаблон"` убивает собственную оболочку, если шаблон есть в самой команде. Останавливайте процессы по порту (`fuser -k 8787/tcp`) или по PID.
|
||||||
|
- `cp` и `ls` в интерактивном окружении могут быть алиасами (запрос на перезапись, цветной вывод): используйте `command cp -f`, `command ls`.
|
||||||
|
- Рабочий каталог сбрасывается после каждой команды, пишите абсолютные пути.
|
||||||
|
- Система может блокировать действия вроде добавления ключей в `authorized_keys` на сервере. Это нормально: подготовьте всё остальное и скажите владельцу, что сделать руками.
|
||||||
|
- Headless Chromium из Playwright по умолчанию светлый, поэтому для проверок подходит; Lighthouse можно запускать с его бинарником.
|
||||||
|
|
||||||
|
**Ловушки в вёрстке.**
|
||||||
|
- Вложенные `display: grid` раздувают колонку по содержимому: ставьте `grid-template-columns: minmax(0, 1fr)`, иначе карточка выходит за край на телефоне.
|
||||||
|
- Анимацию появления по прокрутке нельзя вешать на первый кадр страницы (LCP вырастает в разы): есть исключения для `.gallery--one`, `.shot--lead` первого проекта.
|
||||||
|
- Страницы с данными из JSON прячутся до загрузки только на главной (`body[data-hide]`); на внутренних страницах `has-nav` проставлен прямо в HTML, чтобы меню не двигало вёрстку.
|
||||||
|
- `@view-transition` и `animation-timeline: view()` работают не везде; везде есть запасной вариант без анимации.
|
||||||
|
|
||||||
|
**Что осталось сделать (на момент написания).**
|
||||||
|
1. Убедиться, что в Gitea есть runner и CI срабатывает на пуш (секрет `DEPLOY_SSH_KEY` уже добавлен). Удалить с сервера `/opt/vizitka-site.old-*`.
|
||||||
|
2. Выбрать домен, включить HTTPS через прокси или туннель, заменить заглушку `example.com` (раздел 5).
|
||||||
|
3. Реальные ники (в `config.json` у Telegram и Instagram стоят короткие ники и их handle могут быть временными), проверка текстов про игру и про хоумлаб.
|
||||||
|
4. Страницы Тренировки, Работа, Фикс (черновики уже лежат в `web/`).
|
||||||
|
5. Фото сервера для хоумлаба вместо иллюстрации, больше кадров игры (Backrooms, песочница, паркур).
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# nginx: статический сайт-визитка. Репозиторий клонирован в /opt/vizitka-site, раздаём его папку web/
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /opt/vizitka-site/web;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# сжатие
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 512;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml text/plain application/xml;
|
||||||
|
|
||||||
|
# заголовки безопасности (строгий CSP: на сайте нет inline-скриптов и стилей, внешних ресурсов нет)
|
||||||
|
server_tokens off;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# кэш: картинки и шрифты надолго, стили и скрипты на час, данные без кэша
|
||||||
|
location ~* \.(woff2|webp|png|jpe?g|svg|ico)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000" always;
|
||||||
|
# add_header в location сбрасывает наследуемые, поэтому дублируем безопасные заголовки
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
}
|
||||||
|
location ~* \.(css|js)$ {
|
||||||
|
add_header Cache-Control "public, max-age=3600" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
}
|
||||||
|
location = /config.json {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# скрытые файлы наружу не отдаём
|
||||||
|
location ~ /\. { deny all; }
|
||||||
|
|
||||||
|
error_page 404 /404.html;
|
||||||
|
location = /404.html { internal; }
|
||||||
|
}
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Разовая подготовка сервера: nginx, git, клон репозитория в /opt/vizitka-site. Безопасно запускать повторно.
|
||||||
|
# Использование: ./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}" # репозиторий публичный: ключи не нужны
|
||||||
|
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'
|
||||||
|
set -euo pipefail
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if ! command -v nginx >/dev/null || ! command -v git >/dev/null; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq nginx git >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIR=/opt/vizitka-site
|
||||||
|
if [ -d "$DIR/.git" ]; then
|
||||||
|
git -C "$DIR" pull --ff-only
|
||||||
|
else
|
||||||
|
[ -e "$DIR" ] && mv "$DIR" "$DIR.old-$(date +%s)" # старое содержимое (не репозиторий) откладываем, не удаляем
|
||||||
|
git clone "$REPO_URL" "$DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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))"
|
||||||
|
REMOTE
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Генератор страниц раздела «Архитектура»: список /arch/ и страницы проектов /arch/<проект>/.
|
||||||
|
Запуск из корня репозитория: python3 tools/gen_arch.py
|
||||||
|
Содержание проектов (тексты, картинки, подписи) лежит в списке CASES ниже."""
|
||||||
|
import os, html
|
||||||
|
W = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'web') + '/'
|
||||||
|
E = html.escape
|
||||||
|
|
||||||
|
def head(title, desc, og, depth=1):
|
||||||
|
return f'''<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>{E(title)}: Аман Ибраш</title>
|
||||||
|
<meta name="description" content="{E(desc)}">
|
||||||
|
<meta name="theme-color" content="#f4f6fb">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<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: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}">
|
||||||
|
<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">
|
||||||
|
<script src="/js/boot.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="doc has-nav">
|
||||||
|
<div class="blob blob--a" aria-hidden="true"></div>
|
||||||
|
<div class="blob blob--b" aria-hidden="true"></div>
|
||||||
|
<nav class="topnav" id="topnav" aria-label="Страницы"></nav>
|
||||||
|
'''
|
||||||
|
TAIL = '''
|
||||||
|
<script src="/js/nav.js" defer></script>
|
||||||
|
<script src="/js/count.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------- данные проектов ----------
|
||||||
|
CASES = [
|
||||||
|
dict(slug='quiet-courtyard', title='Тихий двор', sub='Дом в Конаеве вокруг внутреннего двора',
|
||||||
|
year='2026', place='Конаев', kind='жилой дом', cover='house-pond', og='arch-quiet-courtyard.jpg',
|
||||||
|
card='Бетон, дерево и двор, в который приходят небо и свет.',
|
||||||
|
desc='Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.',
|
||||||
|
intro='Дом, который отвернулся от улицы и повернулся к небу.',
|
||||||
|
stats=[('1','этаж',''),('32','м² внутренний двор',''),('41','м² гостиная-кухня',''),('3','спальни','')],
|
||||||
|
sections=[
|
||||||
|
('Улица остаётся снаружи', 'Со стороны улицы это глухая бетонная стена с одной узкой щелью света. Она прячет вход от чужих глаз: чтобы попасть в дом, нужно пройти вдоль сада и только потом повернуть к двери. Рядом под бетонным навесом стоит машина, а сам дом остаётся спокойным и закрытым.'),
|
||||||
|
('Всё собрано вокруг двора', 'Внутри всё держится на дворе площадью 32 м². Утреннее солнце заходит в него, галерея обходит двор по кругу, а комнаты открываются на гравий, воду и тонкие деревья. Над центром плана стоит зенитный фонарь: он приносит верхний свет в самую глубину дома.'),
|
||||||
|
('Бетон и тёплое дерево', 'Главный материал один: бетон с отпечатками опалубки. Рядом с ним тёплое дерево дверей и потолочных обшивок, чёрный металл рам и зелень. Проект сделан в диалоге с архитектурой Тадао Андо: меньше деталей, больше света, тишины и тени.')],
|
||||||
|
gallery=[('house-street','Фасад с улицы: глухая стена и въезд под навес','',1200,896),
|
||||||
|
('house-courtyard','Двор: стекло, дерево и гравий','',1200,896),
|
||||||
|
('house-pond','Фасад в сад: бетон, вода и зелень','',1200,896),
|
||||||
|
('house-gallery','Галерея вокруг двора','',1200,896),
|
||||||
|
('house-corridor','Коридор вдоль двора','',1200,896),
|
||||||
|
('house-entry','Вход: свет и тени на бетоне','',1200,896)],
|
||||||
|
drawings=[('house-concept','Путь и свет: вход с улицы, щель света, путь вдоль сада, зенитный фонарь и утреннее солнце во двор','portrait',1070,1200),
|
||||||
|
('house-plan','План первого этажа с зонированием','portrait',1040,1440),
|
||||||
|
('house-axo','Аксонометрия со срезом','wide',1400,1088)]),
|
||||||
|
dict(slug='exhibition-pavilion', title='Выставочный павильон', sub='Галерея в парке',
|
||||||
|
year='2026', place='Алматы', kind='общественное здание', cover='pav-2', og='arch-exhibition-pavilion.jpg',
|
||||||
|
card='Стеклянная витрина среди деревьев и терраса для скульптур.',
|
||||||
|
desc='Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.',
|
||||||
|
intro='Витрина, которую видно с аллеи ещё до входа.',
|
||||||
|
stats=[('2','этажа',''),('26','м длина здания',''),('2','галереи',''),('1','терраса для скульптур','')],
|
||||||
|
sections=[
|
||||||
|
('Первый этаж как витрина', 'Павильон стоит в парке и работает как витрина: первый этаж почти целиком остеклён, поэтому экспозицию видно с аллеи ещё до входа. Входишь, и сразу попадаешь в холл, из которого открываются обе галереи.'),
|
||||||
|
('Спокойный план', 'Из холла гости идут в две галереи, а лестница ведёт на второй уровень с открытой террасой для скульптур. Служебные помещения собраны в одном крыле, поэтому залы остаются свободными и чистыми для выставок.'),
|
||||||
|
('Три материала', 'Фасад собран из трёх материалов: стеклянная витрина, плоскости бетона и тёплые деревянные ламели. Они держат один спокойный ритм и не спорят с парком вокруг.')],
|
||||||
|
gallery=[('pav-2','Главный фасад','',1600,896),('pav-1','Вход и холл','',1600,893),('pav-3','Вид сверху на парк','',1600,893)],
|
||||||
|
drawings=[('pav-plan','План первого этажа: две галереи и холл','wide',1600,844)]),
|
||||||
|
dict(slug='loft-complex', title='Жилой комплекс «Лофт»', sub='Три корпуса вокруг двора без машин',
|
||||||
|
year='2026', place='Алматы', kind='жилой комплекс', cover='loft-0', og='arch-loft-complex.jpg',
|
||||||
|
card='Красный кирпич и общий двор, свободный от машин.',
|
||||||
|
desc='Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.',
|
||||||
|
intro='Красный кирпич, арки на первом этаже и двор, где можно гулять.',
|
||||||
|
stats=[('3','корпуса',''),('2,11','га участок',''),('7','этажей, корпус 1',''),('1','подземный паркинг','')],
|
||||||
|
sections=[
|
||||||
|
('Двор без машин', 'Комплекс стоит на участке 2,11 га в Турксибском районе Алматы. Три корпуса образуют общий двор, и машин в нём нет: парковка спрятана под землёй, а на поверхности остались площадки и озеленение.'),
|
||||||
|
('Кирпич и арки', 'На первом этаже коммерция за арочными стальными витражами. Выше идут жилые этажи с окнами в стальных рамах. Фасад корпуса 1 выполнен из красного лицевого кирпича, а стальной пояс отделяет коммерческий этаж от квартир.')],
|
||||||
|
gallery=[('loft-0','Общий вид комплекса','',1600,1198)],
|
||||||
|
drawings=[('loft-facade','Боковые фасады корпуса 1','wide',1600,877)]),
|
||||||
|
]
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
def dims(img):
|
||||||
|
o = subprocess.run(['magick', 'identify', '-format', '%w %h', W + f'img/arch/{img}.webp'], capture_output=True, text=True).stdout.split()
|
||||||
|
return int(o[0]), int(o[1])
|
||||||
|
def srcset(img):
|
||||||
|
w, _ = dims(img)
|
||||||
|
parts = []
|
||||||
|
for suf, ww in (('-sm', 640), ('-md', 960)):
|
||||||
|
if os.path.exists(W + f'img/arch/{img}{suf}.webp') and ww < w: parts.append(f'/img/arch/{img}{suf}.webp {ww}w')
|
||||||
|
parts.append(f'/img/arch/{img}.webp {w}w')
|
||||||
|
return ', '.join(parts)
|
||||||
|
|
||||||
|
def fig(img, cap, kind, w_unused, h_unused, lead=False, sizes='(min-width: 62rem) 62rem, calc(100vw - 2.2rem)'):
|
||||||
|
w, h = dims(img)
|
||||||
|
cls = 'shot' + (f' shot--{kind}' if kind else '') + (' shot--lead' if lead else '')
|
||||||
|
lz = ' fetchpriority="high"' if lead else ' loading="lazy" decoding="async" fetchpriority="low"'
|
||||||
|
return f''' <figure class="{cls}">
|
||||||
|
<a href="/img/arch/{img}.webp" target="_blank" rel="noopener" aria-label="Открыть крупно: {E(cap)}"><img src="/img/arch/{img}-md.webp" srcset="{srcset(img)}" sizes="{sizes}" width="{w}" height="{h}" alt="{E(cap)}"{lz}></a>
|
||||||
|
<figcaption>{E(cap)}</figcaption>
|
||||||
|
</figure>
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------- страницы проектов ----------
|
||||||
|
for i, c in enumerate(CASES):
|
||||||
|
nxt = CASES[(i + 1) % len(CASES)]
|
||||||
|
cover = next(g for g in c['gallery'] if g[0] == c['cover'])
|
||||||
|
rest = [g for g in c['gallery'] if g[0] != c['cover']]
|
||||||
|
meta = ''.join(f'<li>{E(x)}</li>' for x in (c['year'], c['place'], c['kind']))
|
||||||
|
stats = ''.join(f'<li class="stat"><b data-count="{n.replace(",", ".")}">{n}</b><span>{E(l)}</span></li>' for n, l, _ in c['stats'])
|
||||||
|
secs = ''.join(f'<h2>{E(t)}</h2><p>{E(p)}</p>' for t, p in c['sections'])
|
||||||
|
gal = ''.join(fig(g[0], g[1], g[2], g[3], g[4]) for g in rest)
|
||||||
|
dr = ''.join(fig(g[0], g[1], g[2], g[3], g[4]) for g in c['drawings'])
|
||||||
|
h = head(c['title'], c['desc'], c['og'])
|
||||||
|
h += f'''
|
||||||
|
<main class="page page--wide">
|
||||||
|
<p class="back"><a href="/arch/">← Архитектура</a></p>
|
||||||
|
<header class="case-head">
|
||||||
|
<ul class="meta">{meta}</ul>
|
||||||
|
<h1>{E(c['title'])}</h1>
|
||||||
|
<p class="lead">{E(c['sub'])}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="gallery gallery--one">
|
||||||
|
{fig(cover[0], cover[1], 'cover', cover[3], cover[4], lead=True)} </div>
|
||||||
|
|
||||||
|
<ul class="case-stats">{stats}</ul>
|
||||||
|
|
||||||
|
<section class="case-body">
|
||||||
|
<p class="intro">{E(c['intro'])}</p>
|
||||||
|
{secs}
|
||||||
|
</section>
|
||||||
|
'''
|
||||||
|
if rest:
|
||||||
|
h += f'''
|
||||||
|
<section class="case-block" aria-label="Визуализации">
|
||||||
|
<h2 class="block-title">Кадры</h2>
|
||||||
|
<div class="gallery">
|
||||||
|
{gal} </div>
|
||||||
|
</section>
|
||||||
|
'''
|
||||||
|
h += f'''
|
||||||
|
<section class="case-block" aria-label="Чертежи">
|
||||||
|
<h2 class="block-title">Чертежи и схемы</h2>
|
||||||
|
<div class="gallery">
|
||||||
|
{dr} </div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a class="next-case" href="/arch/{nxt['slug']}/">
|
||||||
|
<span class="muted-w">Следующий проект</span>
|
||||||
|
<span class="next-title">{E(nxt['title'])}</span>
|
||||||
|
<span class="next-arrow" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</main>
|
||||||
|
''' + TAIL
|
||||||
|
os.makedirs(W + f"arch/{c['slug']}", exist_ok=True)
|
||||||
|
open(W + f"arch/{c['slug']}/index.html", 'w').write(h)
|
||||||
|
|
||||||
|
# ---------- список проектов ----------
|
||||||
|
cards = ''
|
||||||
|
for i, c in enumerate(CASES):
|
||||||
|
lead = ' work--lead' if i == 0 else ''
|
||||||
|
cards += f''' <a class="work{lead}" href="/arch/{c['slug']}/">
|
||||||
|
<div class="pic"><img src="/img/arch/{c['cover']}-md.webp" srcset="{srcset(c['cover'])}" sizes="(min-width: 62rem) 50vw, calc(100vw - 2.2rem)" width="{dims(c['cover'])[0]}" height="{dims(c['cover'])[1]}" alt="{E(c['title'])}"{' fetchpriority="high"' if i == 0 else ' loading="lazy" decoding="async" fetchpriority="low"'}></div>
|
||||||
|
<div class="info">
|
||||||
|
<ul class="meta"><li>{c['year']}</li><li>{E(c['place'])}</li></ul>
|
||||||
|
<h2>{E(c['title'])}</h2>
|
||||||
|
<p>{E(c['card'])}</p>
|
||||||
|
<span class="more">Смотреть проект <span aria-hidden="true">→</span></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
'''
|
||||||
|
idx = head('Архитектура', 'Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.', 'arch.jpg')
|
||||||
|
idx += f'''
|
||||||
|
<main class="page page--wide">
|
||||||
|
<header class="page-intro">
|
||||||
|
<p class="hello">Архитектура</p>
|
||||||
|
<h1>Рисую здания</h1>
|
||||||
|
<p class="lead">Я студент KazGASA в Алматы. Здесь проекты, которыми горжусь больше всего. Выбери любой и загляни внутрь.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="works" aria-label="Проекты">
|
||||||
|
{cards} </section>
|
||||||
|
|
||||||
|
<aside class="soon">
|
||||||
|
<p class="hello">Дальше</p>
|
||||||
|
<p>Здесь будет больше: учёба, эскизы и то, что я вижу и фотографирую. Загляни позже.</p>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
''' + TAIL
|
||||||
|
open(W + 'arch/index.html', 'w').write(idx)
|
||||||
|
|
||||||
|
# sitemap: дописываем недостающие адреса, чужие не трогаем
|
||||||
|
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>')
|
||||||
|
open(sm_path, 'w').write(sm)
|
||||||
|
print('arch pages:', [c['slug'] for c in CASES])
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Генератор страниц раздела «Проекты»: список /projects/, игра /projects/amans-world/ и хоумлаб /projects/homelab/.
|
||||||
|
Запуск из корня репозитория: python3 tools/gen_projects.py (сначала должен существовать tools/gen_arch.py: из него берётся общая шапка)"""
|
||||||
|
import os, html, subprocess, re
|
||||||
|
W = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'web') + '/'
|
||||||
|
E = html.escape
|
||||||
|
src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'gen_arch.py')).read()
|
||||||
|
# берём head() и TAIL из генератора архитектуры, чтобы шапка была одинаковой
|
||||||
|
ns = {'os': os, 'html': html, 'W': W, 'E': E}
|
||||||
|
exec(src[src.index('def head('):src.index('# ---------- данные проектов ----------')], ns)
|
||||||
|
head, TAIL = ns['head'], ns['TAIL']
|
||||||
|
|
||||||
|
def dims(img):
|
||||||
|
o = subprocess.run(['magick', 'identify', '-format', '%w %h', W + f'img/proj/{img}.webp'], capture_output=True, text=True).stdout.split()
|
||||||
|
return int(o[0]), int(o[1])
|
||||||
|
def srcset(img):
|
||||||
|
w, _ = dims(img); parts = []
|
||||||
|
for suf, ww in (('-sm', 640), ('-md', 960)):
|
||||||
|
if os.path.exists(W + f'img/proj/{img}{suf}.webp') and ww < w: parts.append(f'/img/proj/{img}{suf}.webp {ww}w')
|
||||||
|
parts.append(f'/img/proj/{img}.webp {w}w'); return ', '.join(parts)
|
||||||
|
def fig(img, cap, kind='', lead=False):
|
||||||
|
w, h = dims(img)
|
||||||
|
cls = 'shot' + (f' shot--{kind}' if kind else '') + (' shot--lead' if lead else '')
|
||||||
|
lz = ' fetchpriority="high"' if lead else ' loading="lazy" decoding="async" fetchpriority="low"'
|
||||||
|
return f''' <figure class="{cls}">
|
||||||
|
<a href="/img/proj/{img}.webp" target="_blank" rel="noopener" aria-label="Открыть крупно: {E(cap)}"><img src="/img/proj/{img}-md.webp" srcset="{srcset(img)}" sizes="(min-width: 62rem) 62rem, calc(100vw - 2.2rem)" width="{w}" height="{h}" alt="{E(cap)}"{lz}></a>
|
||||||
|
<figcaption>{E(cap)}</figcaption>
|
||||||
|
</figure>
|
||||||
|
'''
|
||||||
|
GAME = 'https://grid.ibrash.duckdns.org/'
|
||||||
|
# ---------- страница игры ----------
|
||||||
|
title = "Aman's World"
|
||||||
|
desc = "Aman's World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only."
|
||||||
|
stats = [('5','порталов в хабе'),('8','чекпоинтов, Sector 3 Run'),('13','чекпоинтов, Ascend Only'),('40','МБ при первом запуске')]
|
||||||
|
secs = [
|
||||||
|
('Неоновый хаб', 'Ты начинаешь в хабе: зал с неоновыми колоннами, вращающейся скульптурой-атомом и порталами на карты. Над гоночными порталами висят таблицы рекордов. Перед входом вводишь ник до 16 символов.'),
|
||||||
|
('Паркур', 'Движение собрано под паркур: бег, прыжок, двойной прыжок, рывок, скольжение, бег по стенам, прыжок от стены и подтягивание на уступы. На трассах лежат полосы-ускорители.'),
|
||||||
|
('Две гоночные трассы', 'Sector 3 Run гонит по вечернему оранжевому городу, на трассе 8 чекпоинтов. Ascend Only ведёт всё выше по фиолетовому ночному городу, там 13 чекпоинтов. Перед стартом нажимаешь R, то есть «готов», а счётчик (0/1) показывает, сколько игроков готово. Время идёт, и побеждает самый быстрый.'),
|
||||||
|
('На чём играть', 'Игра работает только на компьютере, на телефоне она не запустится. Управлять можно клавиатурой и мышью или геймпадом вроде Xbox.'),
|
||||||
|
('Песочница и Backrooms', 'Ещё из хаба можно уйти в песочницу, где просто отрабатываешь движение, и на карту Backrooms.'),
|
||||||
|
('Под капотом', 'Игру я сделал сам на движке Godot 4 и выложил на свой сервер. При первом запуске браузер загружает около 40 МБ, зато потом всё работает быстро.')]
|
||||||
|
stat_html = ''.join(f'<li class="stat"><b data-count="{n}">{n}</b><span>{E(l)}</span></li>' for n, l in stats)
|
||||||
|
body = ''.join(f'<h2>{E(t)}</h2><p>{E(p)}</p>' for t, p in secs)
|
||||||
|
h = head(title, desc, 'projects-amans-world.jpg')
|
||||||
|
h += f'''
|
||||||
|
<main class="page page--wide">
|
||||||
|
<p class="back"><a href="/projects/">← Проекты</a></p>
|
||||||
|
<header class="case-head">
|
||||||
|
<ul class="meta"><li>3D</li><li>в браузере</li><li>только на ПК</li><li>Godot 4</li></ul>
|
||||||
|
<h1>{E(title)}</h1>
|
||||||
|
<p class="lead">Паркур и гонки в неоновом городе. Запускается прямо в браузере.</p>
|
||||||
|
<div class="actions"><a class="btn btn--primary" href="{GAME}" target="_blank" rel="noopener">Играть</a><p class="note">Работает только на компьютере. Можно играть с геймпадом (Xbox).</p></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="gallery gallery--one">
|
||||||
|
{fig('game-ascend','Ascend Only: старт трассы вверх, 13 чекпоинтов', 'cover', True)} </div>
|
||||||
|
|
||||||
|
<ul class="case-stats">{stat_html}</ul>
|
||||||
|
|
||||||
|
<section class="case-body">
|
||||||
|
<p class="intro">Игра, в которую можно зайти по ссылке. Без установки и без регистрации.</p>
|
||||||
|
{body}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="case-block" aria-label="Кадры из игры">
|
||||||
|
<h2 class="block-title">Кадры из игры</h2>
|
||||||
|
<div class="gallery">
|
||||||
|
{fig('game-sector3','Sector 3 Run: старт трассы, 8 чекпоинтов')}{fig('game-hub','Хаб: порталы Sandbox, Backrooms, Race и Race 2')}{fig('game-splash','Заставка игры', 'wide')} </div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="soon">
|
||||||
|
<p class="hello">Готов проверить?</p>
|
||||||
|
<p>Зайди, выбери портал и попробуй пробежать трассу. Перед гонкой не забудь нажать R.</p>
|
||||||
|
<div class="actions"><a class="btn btn--primary" href="{GAME}" target="_blank" rel="noopener">Играть в Aman's World</a></div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
''' + TAIL
|
||||||
|
os.makedirs(W + 'projects/amans-world', exist_ok=True)
|
||||||
|
open(W + 'projects/amans-world/index.html', 'w').write(h)
|
||||||
|
|
||||||
|
CARD2 = ''' <a class="work work--lead" href="/projects/homelab/">
|
||||||
|
<div class="pic"><img src="/img/proj/homelab-cover.svg" width="1200" height="630" alt="Иллюстрация: домашний сервер с тремя сервисами" loading="lazy" decoding="async"></div>
|
||||||
|
<div class="info">
|
||||||
|
<ul class="meta"><li>дома</li><li>Linux</li><li>Fedora</li></ul>
|
||||||
|
<h2>Хоумлаб</h2>
|
||||||
|
<p>Мой домашний сервер: файлы, игра и эксперименты. И Linux, на котором я всё делаю.</p>
|
||||||
|
<span class="more">Смотреть проект <span aria-hidden="true">→</span></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
'''
|
||||||
|
# ---------- список проектов ----------
|
||||||
|
w0, h0 = dims('game-ascend')
|
||||||
|
idx = head('Проекты', "Мои проекты: Aman's World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.", 'projects.jpg')
|
||||||
|
idx += f'''
|
||||||
|
<main class="page page--wide">
|
||||||
|
<header class="page-intro">
|
||||||
|
<p class="hello">Проекты</p>
|
||||||
|
<h1>Что я делаю</h1>
|
||||||
|
<p class="lead">Кроме учёбы и работы я собираю свои штуки. Выбери проект и загляни внутрь.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="works" aria-label="Проекты">
|
||||||
|
<a class="work work--lead" href="/projects/amans-world/">
|
||||||
|
<div class="pic"><img src="/img/proj/game-ascend-md.webp" srcset="{srcset('game-ascend')}" sizes="(min-width: 62rem) 50vw, calc(100vw - 2.2rem)" width="{w0}" height="{h0}" alt="Aman's World: старт трассы Ascend Only" fetchpriority="high"></div>
|
||||||
|
<div class="info">
|
||||||
|
<ul class="meta"><li>3D</li><li>в браузере</li><li>только на ПК</li><li>Godot 4</li></ul>
|
||||||
|
<h2>Aman's World</h2>
|
||||||
|
<p>Паркур и гонки в неоновом городе. Запускается прямо в браузере, ничего ставить не надо.</p>
|
||||||
|
<span class="more">Смотреть проект <span aria-hidden="true">→</span></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
''' + CARD2 + ''' </section>
|
||||||
|
|
||||||
|
<aside class="soon">
|
||||||
|
<p class="hello">Дальше</p>
|
||||||
|
<p>Тут появятся новые проекты, как только доделаю. Заглядывай.</p>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
''' + TAIL
|
||||||
|
open(W + 'projects/index.html', 'w').write(idx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- страница хоумлаба ----------
|
||||||
|
hl_title = 'Хоумлаб'
|
||||||
|
hl_desc = 'Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.'
|
||||||
|
hl = head(hl_title, hl_desc, 'projects-homelab.jpg')
|
||||||
|
hl += '''
|
||||||
|
<main class="page page--wide">
|
||||||
|
<p class="back"><a href="/projects/">← Проекты</a></p>
|
||||||
|
<header class="case-head">
|
||||||
|
<ul class="meta"><li>дома</li><li>Linux</li><li>Fedora</li></ul>
|
||||||
|
<h1>Хоумлаб</h1>
|
||||||
|
<p class="lead">Мой домашний сервер и Linux, на котором я всё делаю.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="gallery gallery--one">
|
||||||
|
<figure class="shot shot--cover shot--lead">
|
||||||
|
<a href="/img/proj/homelab-cover.svg" target="_blank" rel="noopener" aria-label="Открыть иллюстрацию крупно"><img src="/img/proj/homelab-cover.svg" width="1200" height="630" alt="Иллюстрация: домашний сервер с тремя сервисами" fetchpriority="high"></a>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="case-body">
|
||||||
|
<p class="intro">Свой маленький интернет у меня дома.</p>
|
||||||
|
<h2>Что это</h2>
|
||||||
|
<p>Хоумлаб — это мой домашний сервер. Я работаю системным администратором и расту в сторону DevOps, поэтому держу дома место, где можно пробовать всё на настоящем железе.</p>
|
||||||
|
<p>Дома можно спокойно пробовать, ломать и чинить. Так я учусь автоматизации и всему, что окружает современный сервер.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="case-block" aria-label="Что живёт на сервере">
|
||||||
|
<h2 class="block-title">Что на нём живёт</h2>
|
||||||
|
<div class="tiles">
|
||||||
|
<div class="tile"><h3>Nextcloud</h3><p>Мои файлы и проекты лежат у меня, а не в чужом облаке.</p></div>
|
||||||
|
<a class="tile" href="/projects/amans-world/"><h3>Aman's World</h3><p>Моя браузерная игра запускается с этого сервера.</p><span class="more">Смотреть игру →</span></a>
|
||||||
|
<div class="tile"><h3>DuckDNS</h3><p>Бесплатные адреса, чтобы до домашнего сервера можно было достучаться снаружи.</p></div>
|
||||||
|
<div class="tile"><h3>Этот сайт</h3><p>Он тоже переезжает сюда. Выкладку хочу автоматизировать через CI.</p></div>
|
||||||
|
<div class="tile tile--more"><h3>И много чего ещё</h3><p>Список растёт: я постоянно что-то поднимаю и пробую.</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="case-body">
|
||||||
|
<h2>Fedora Linux</h2>
|
||||||
|
<p>На своём компьютере я пользуюсь Fedora Linux. В ней я учусь, работаю и делаю свои проекты, в том числе этот сайт.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
''' + TAIL
|
||||||
|
os.makedirs(W + 'projects/homelab', exist_ok=True)
|
||||||
|
open(W + 'projects/homelab/index.html', 'w').write(hl)
|
||||||
|
|
||||||
|
# sitemap: дописываем недостающие адреса
|
||||||
|
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>')
|
||||||
|
open(sm_path, 'w').write(sm)
|
||||||
|
print('projects pages: ok')
|
||||||
Reference in New Issue
Block a user