Compare commits

..
69 Commits
Author SHA1 Message Date
erjemin 9d3584e96e mod: ядро (04) SVG-генерация записывается в базу 2026-08-27 00:06:15 +03:00
erjemin 58c1ee46df mod: админка (15) в save_model работает типограф etpgrf и авто-slug с транслитерацией + превью-картинки в списке блог-постов 2026-08-25 22:31:39 +03:00
erjemin 19d383dc89 add: pytils (для транслитерации и т.п.) 2026-08-25 21:34:06 +03:00
erjemin 457c33725a mod: админка (14) настроены поля типографа 2026-08-25 20:48:05 +03:00
erjemin 13641eae88 add: типограф etpgrf в проект (для блогов) 2026-08-24 21:16:27 +03:00
erjemin 6f9da30040 add: админка (13) CodeMirror в админке настроен и работает... 2026-08-24 21:14:21 +03:00
erjemin 1d5306a48e add: модели и админка (12) миксины, стили и патч для CodeMirror в админке... 2026-08-24 17:51:36 +03:00
erjemin 8a7de1f062 add: модели и админка (12) миграции 2026-08-24 17:03:46 +03:00
erjemin 752f1506e3 mod: интерфейс (10) превью картинки. перед "скармливанием Гипоножабе" 2026-08-23 23:24:47 +03:00
erjemin ba1f1c2a0b add: заглушка для счетчиков аналитики... 2026-08-23 22:58:14 +03:00
erjemin 3128ae4e2d mod: ядро (03) уменьшены максимальные значения "плотности галлюцинации" т.к. браузер большую сетку может не тянуть... 2026-08-23 22:56:58 +03:00
erjemin 4917e04e5a add: ядро (02) драфт... 2026-08-23 22:53:57 +03:00
erjemin 8bd5a54cf5 add: csrf в шаблонах 2026-08-23 22:27:06 +03:00
erjemin 05bb583d8a add: core (01) драфт... работает!! Генерация SVG проходит!!! 2026-08-23 22:14:49 +03:00
erjemin f015351c42 mod: админка (11) драфт ver2 2026-08-23 18:27:22 +03:00
erjemin 1a285a61c6 минор: Custom Command переименование rescore_hypn0_items -> rescore 2026-08-23 17:23:00 +03:00
erjemin 200b81987c add: Custom Command Синхронизация s_hash_id с солью 2026-08-23 17:16:06 +03:00
erjemin 27049e9ae0 mod: модели (10) метод save() модели TbHypn0Item 2026-08-23 16:51:00 +03:00
erjemin b63dbb6bd2 add: документация по Custom Command (1) 2026-08-22 20:32:33 +03:00
erjemin b23784b7ad add: Custom Command перерасчета рейтингов (2) все "магические числа" определены вначале. 2026-08-22 20:31:54 +03:00
erjemin 87c8e79630 add: драфт Django Custom Command перерасчета рейтингов. 2026-08-22 19:52:42 +03:00
erjemin 9777c93cb4 add: подготовка для Django Custom Command 2026-08-22 19:52:12 +03:00
erjemin bb4ec185ff mod: mod: модели (9) убран "атомарный" перерасчет рейтинга. 2026-08-22 19:51:14 +03:00
erjemin 3cef695782 mod: mod: модели (8) методы для лайков/дислайков 2026-08-22 18:02:55 +03:00
erjemin cc3a17e792 mod: согласие на отслеживание совмещено с фингерпринтом браузера UUIDv4 2026-08-22 15:55:39 +03:00
erjemin 2bec2e860e mod: про модели и хранение данных... 2026-08-22 14:58:32 +03:00
erjemin 381ec3eaf0 mod: модели и админка (7) + миграции 2026-08-22 14:26:57 +03:00
erjemin eff510eb4b mod: модели и админка (6) драфт 2026-08-20 16:35:42 +03:00
erjemin 99c317431e mod: Срабатывает кнопка Подчиниться Гипножабе! 2026-08-20 13:01:39 +03:00
erjemin e49154090e add: codemirror для админки и его сборщик 2026-08-19 16:24:49 +03:00
erjemin 31fb267d98 mod: модели и админка (5) драфт 2026-08-19 15:52:54 +03:00
erjemin 72855a2c32 mod: модели и админка (4) драфт 2026-08-18 17:34:14 +03:00
erjemin 816744bbfc mod: модели и админка (3) драфт 2026-08-18 17:05:50 +03:00
erjemin 6850df10e2 mod: модели и админка (2) драфт 2026-08-18 17:03:35 +03:00
erjemin 5ec89c323d add: модели и админка (1) драфт 2026-08-17 23:53:54 +03:00
erjemin d430ac5f0a mod: принципы приватности 2026-08-17 23:42:38 +03:00
erjemin 4005373755 mod: планы по моделям, хранению данных, защите от накруток в голосовании. 2026-08-17 23:24:08 +03:00
erjemin 6c23be1f4b fix: Комментарии. Браузеры смотрят на первые байт файла, чтобы определить кодировку (из <meta charset>). 2026-08-17 12:09:53 +03:00
erjemin 93200a698a del: картинка больше не нужна 2026-08-17 00:32:34 +03:00
erjemin 5e8a0dbb8b minor: жаба 2026-08-17 00:30:32 +03:00
erjemin 7591913c68 add: жаба 2026-08-17 00:25:23 +03:00
erjemin 147f3b4153 add: инструкции агента Junie 2026-08-17 00:22:08 +03:00
erjemin 71f352d003 minor: прогресс-бар менее прозрачный 2026-08-17 00:21:11 +03:00
erjemin eedc304560 mod: жаба (которую кормить). 2026-08-17 00:04:11 +03:00
erjemin 9ccc6fed7b add: гипноспираль! 2026-08-17 00:03:37 +03:00
erjemin c65509f14a minor: комментарии 2026-08-16 23:38:34 +03:00
erjemin a6a9d3dc48 mod: интерфейс (9) блокирование интерфейса на время генерации и прогресс-бар (глюко-бар) 2026-08-16 22:57:29 +03:00
erjemin 8ed24d3291 mod: как делать превью галереи и не "обрушить" браузер! 2026-08-16 22:55:54 +03:00
erjemin 60438d05bd mod: долгосрочный роадмап 2026-08-16 21:29:18 +03:00
erjemin bbd8144c70 mod: интерфейс (8) трехэтажная галерея 2026-08-16 21:09:43 +03:00
erjemin 44b5085b99 mod: интерфейс (7) выровнены колонки по высоте + лайк/дисдайк у готовой картинки 2026-08-16 20:46:40 +03:00
erjemin c0cb0a2af9 mod: интерфейс (6) выбранная глюко-фигура окрашивается. 2026-08-16 17:45:11 +03:00
erjemin c791df7b0d mod: интерфейс (5) больше вариантов глюко-точек 2026-08-16 17:41:34 +03:00
erjemin e8cfaf67b7 mod: интерфейс (4) выбор цветов дуатон и триатон. 2026-08-16 17:19:43 +03:00
erjemin 90edb6c8a3 mod: интерфейс (3) цифры (там где нужно) имеют значения (градусы и проценты) 2026-08-16 16:24:01 +03:00
erjemin 096e55828d mod: интерфейс (2) ползунки и цифры меняют цвет 2026-08-16 15:51:37 +03:00
erjemin 60e04910a1 minor: типографика 2026-08-15 21:36:24 +03:00
erjemin 6bf884223f fix: удалены ненужные темы... 2026-08-15 21:26:16 +03:00
erjemin 8dc84238e4 add: интерфейс (1) 2026-08-15 21:22:00 +03:00
erjemin a4fff36407 minor: отступы и подключаемый блок EXTRA_LD_JSON 2026-08-15 21:21:29 +03:00
erjemin 811037fe80 fix: символы темной и светлой темы 2026-08-15 19:27:27 +03:00
erjemin 49d0d5aaa4 mod: TailWInd слили 2026-08-15 19:26:32 +03:00
erjemin 8605d6991c mod: работает переключение темной/светлой темы. 2026-08-15 18:00:34 +03:00
erjemin ee4eb75617 add: шапка 2026-08-15 16:50:21 +03:00
erjemin e7dbd05eaa add: все favicon 2026-08-15 13:04:53 +03:00
erjemin 40c38f08cf mod: вращающаяся спираль + микро-оптимизация 2026-08-14 23:16:51 +03:00
erjemin 7e7c39c6d5 minor: оптимизация 2026-08-14 23:15:46 +03:00
erjemin a5f2d43db6 mod: вращение и мерцание цвета 2026-08-14 22:41:06 +03:00
erjemin 815d6c9e41 minor: изменения в интерфейсе 2026-08-14 21:49:45 +03:00
49 changed files with 5906 additions and 193 deletions
+13
View File
@@ -17,6 +17,19 @@ DJANGO_EMAIL_HOST_USER=you@email.com
DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME
DJANGO_EMAIL_FROM=you@email.com DJANGO_EMAIL_FROM=you@email.com
# ====== HASHIDS настройка для TbHypn0Item.s_hash_id ======
# Используется для кодирования ID генераций SVG в компактные, хаотичные коды (не криптографический хеш, а encoder).
# - HASHIDS_SALT: секретная соль для кодирования (ВАЖНО! Менять для production!)
# Только с солью можно декодировать, без нее невозможно восстановить ID
# Генерируйте криптографически стойкую случайную строку: python -c "import secrets; print(secrets.token_hex(16))"
# Содержит только буквы и цифры (безопасно для URL и QR-кода)
# - HASHIDS_MIN_LENGTH: минимальная длина кода
# 6 символов = оптимально для каталогов до 100k
# 8 символов = для каталогов 100k-10M
# Рекомендация: оставить 6 для начальной фазы, поменять на 8 при росте
HASHIDS_SALT=your-super-secret-salt-for-production-change-me-now
HASHIDS_MIN_LENGTH=6
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти) # URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
ADMIN_URL=admin/ ADMIN_URL=admin/
+48
View File
@@ -0,0 +1,48 @@
# Сведения для разработчиков (AGENTS.md)
Проект **HypnoSVG** — микросайт на Django для генерации анимированных SVG-халфтонов.
### Стек технологий
- **Backend:** Django 6.0.8+, Python 3.14+
- **Frontend:** HTMX (интерактивность), Alpine.js (клиентская логика), Tailwind CSS 4 (CDN в dev, v3 в prod — в процессе перехода).
- **Графика:** Pillow (обработка), в планах scikit-learn (k-means) и numpy.
- **Инфраструктура:** Poetry (зависимости), Whitenoise (статика в prod), Gunicorn.
### Архитектура и структура
- **Корневые папки:**
- `hypn0/`: Исходный код Django (проект и основное приложение `hypn0_site`).
- `public/`: Публичная статика и медиа-файлы (разделено для dev/prod).
- `database/`: Файлы БД SQLite (вынесены из корня приложения).
- `frontend-assembly/`: Исходники для сборки фронтенд-библиотек.
- `scripts/`: Скрипты автоматизации сборки фронтенда.
- **Django настройки:**
- `ADMIN_URL`: Настраивается через `.env`, по умолчанию скрыт.
- `context_processors.py`: Предоставляет `IS_DEBUG` и `ALLOW_TRACKING` (GDPR-friendly cookie consent).
- `urls.py`: В режиме `DEBUG` автоматически пробрасывает файлы из корня `public` (favicon, robots.txt и др.).
- **Кастомные стили:**
- `hypn0/templates/css/tailwind-custom.css` — основной файл стилей (директивы Tailwind 4). Подключается инлайново через `include` в `_base.html` (dev) и импортируется в `input.css` (prod).
### Сборка фронтенда
Фронтенд-зависимости не лежат в репозитории как готовые бандлы. Для их обновления используйте скрипты из корня:
- `bash scripts/build-tailwind.sh` (Внимание: скрипт требует обновления до v4 для полной совместимости с `tailwind-custom.css`).
- `bash scripts/build-htmx.sh`
- `bash scripts/build-alpine.sh`
Скрипты используют `npm ci` во временных папках внутри `frontend-assembly/` и копируют результат в `public/static/`.
### Ядро генерации (Plan)
Основная логика переносится из CLI-прототипа в `hypn0_site/services/halftone.py`.
- Вход: `PIL.Image`.
- Выход: Строка SVG с инлайновыми CSS-анимациями.
- Основные параметры: `cols` (плотность сетки), `max_radius`, цветовые схемы.
### Важные нюансы
- **Static & Media:** В `settings.py` пути настроены так, чтобы `public` была общей точкой входа для Nginx.
- **Database:** Используется WAL-режим для SQLite (настраивается в `settings.py` через сигнал `connection_created`).
- **Templates:** Базовый шаблон `_base.html` содержит логику подключения HTMX/Alpine и модалку согласия на трекинг.
### Стиль кода
- **Python:** PEP8, Black, isort. Отступы 4 пробела, max line length 180-200.
- **HTML:** Django templates, Tailwind CSS классы в BEM. Отступы 2 пробела, max line length 250.
- Комментарии в коде на русском, переменные и функции на английском.
- Избегать emojis в коде, комментариях и инструкциях. Сленг и токсичные выражения допускаются.
- В интерфейсе и для пользователей текст в стиле "Гипножаба контролирует ваши действия" (игра слов, юмор, мемы).
+3 -2
View File
@@ -1,9 +1,10 @@
# HypnoSVG # Hypn0
Генератор гипнотических SVG-халтонов из изображений. Загрузи фото → настраивай фигуры, сетки, цвета и анимации → получи залипательную графику. Генератор гипнотических SVG-халтонов из изображений. Загрузи фото → настраивай фигуры, сетки, цвета и анимации → получи залипательную графику.
[![Python 3.12+](https://img.shields.io/badge/Python-3.12-blue)](https://python.org) [![Python 3.12+](https://img.shields.io/badge/Python-3.12-blue)](https://python.org)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Static Badge](https://img.shields.io/badge/ОРИГИНАЛ-git.cube2.ru-green)](https://git.cube2.ru/erjemin/2026-hypn0-site)
## Что это ## Что это
@@ -28,4 +29,4 @@ poetry run python main.py img.jpg
## Демо ## Демо
![Пример](public/static/img/og-hypn0-default.svg) ![Пример](public/static/img/thinking.svg)
+98
View File
@@ -0,0 +1,98 @@
# Django Management Commands
В этом документе описаны кастомные консольные команды (`manage.py`), реализованные в проекте **Hypn0** для автоматизации периодических фоновых задач, скоринга и обслуживания хранилища.
---
## 1. `rescore` — Пакетный пересчет рейтинга и детекция аномалий
- **Путь:** `../hypn0/hypn0_site/management/commands/rescore.py`
- **Назначение:** Пакетный фоновый пересчет рейтинга популярности (`f_score`) для всех активных картин и выявление аномалий (накрутка лайков / скоординированный шейминг).
### Базовый запуск:
```bash
# Рабочий запуск
poetry run python hypn0/manage.py rescore
# Тестовый прогон (без записи изменений в базу)
poetry run python hypn0/manage.py rescore --dry-run
```
---
### Архитектурные принципы и алгоритм:
1. **Метрики платформы («Средняя температура по больнице»):**
- Анализирует активность за скользящее окно `WINDOW_HOURS = 24.0` часов.
- Считает среднее число голосов на одну активную картину (моложе 30 дней).
- **Защита «холодного старта» (Cold Start):** при пустой базе или отсутствии голосов отключаются относительные детекторы, предотвращая ложные срабатывания и деление на 0.
2. **Экспоненциальное затухание голосов (Time-Decay):**
- Сила каждого голоса из `TbVote` плавно затухает по закону:
$$\text{Weight}(t) = \text{BaseWeight} \times 2^{-\Delta t / \text{HalfLife}}$$
- По умолчанию период полураспада силы голоса составляет 14 дней (`--half-life-days=14.0`).
- Истинные веса голосов:
- `LIKE` = $+1$
- `AUTHOR` = $+2$
- `CLAIM` = $-2$
3. **Детекция аномалий и автоматический перевод уровней (`Level`):**
- **Шейминг (`Level.SHAMED = -20`):** если за последние 24ч доля жалоб среди свежих голосов $\ge 35\%$ (`--shame-claim-ratio=0.35`).
- **Накрутка / Спайк (`Level.SUSPICIOUS = -10`):** если приток лайков за окно в 4+ раза превышает средний по платформе (`--spike-multiplier=4.0`) и составляет $\ge 20$ лайков (или $\ge 50$ лайков при холодном старте).
- **Прогрев (`Level.LEVEL_1 = 10`):** если картина со статусом `CANDIDATE` набрала достаточный взвешенный вес ($\ge 10$ по умолчанию), она автоматически повышается до `LEVEL_1` («Плеск бессознательного»), привлекая внимание модератора для витрины.
4. **Гравитационная модель скоринга (с логарифмом):**
$$\text{Score} = \frac{\ln(1 + \max(0, \text{WeightedLikes})) + \text{Bonus}_{\text{level}}}{(\text{Age in hours} + 2.0)^\gamma}$$
- `LEVEL_1_BONUS` = $+1.5$
- `LEVEL_2_BONUS` = $+4.0$ (Одобрено Мозговым Слизнем)
5. **Инерционное сглаживание (EMA — Exponential Moving Average):**
- Чтобы исключить резкие скачки рейтинга от разовых всплесков, итоговый `f_score` сглаживается с предыдущим значением:
$$\text{Score}_{\text{final}} = 0.7 \times \text{Score}_{\text{new}} + 0.3 \times \text{Score}_{\text{old}}$$
---
### Параметры командной строки (CLI):
| Флаг | Тип | По умолчанию | Описание |
|----------------------------|---------|--------------|------------------------------------------------------------------|
| `--gamma` | `float` | `1.5` | Коэффициент гравитации старения картины |
| `--half-life-days` | `float` | `14.0` | Период полураспада веса голоса (в днях) |
| `--spike-multiplier` | `float` | `4.0` | Множитель спайка лайков для детекции накрутки |
| `--shame-claim-ratio` | `float` | `0.35` | Доля жалоб для признания скоординированного шейминга |
| `--warmup-votes-threshold` | `int` | `10` | Порог взвешенных голосов для автопрогрева `CANDIDATE -> LEVEL_1` |
| `--dry-run` | `flag` | `False` | Пробный запуск без изменения записей в БД |
---
## 2. `rehash` — Проверка целостности и перегенерация `s_hash_id`
- **Путь:** `hypn0/hypn0_site/management/commands/rehash.py`
- **Назначение:** Синхронизация публичных коротких хэшей `s_hash_id` в базе данных с текущим значением `HASHIDS_SALT` и `HASHIDS_MIN_LENGTH`.
### Когда требуется запуск:
1. При смене или ротации `HASHIDS_SALT` в `.env` / `settings.py`.
2. При переносе/миграции базы данных между разными окружениями (Dev -> Prod).
3. При обнаружении записей с пустыми или устаревшими `s_hash_id`.
### Базовый запуск:
```bash
# Тестовая проверка нестыковок (без записи в БД)
poetry run python hypn0/manage.py rehash --dry-run
# Боевая перегенерация
poetry run python hypn0/manage.py rehash
```
### Параметры командной строки (CLI):
| Флаг | Тип | По умолчанию | Описание |
|----------------|--------|--------------|----------------------------------------------------------------|
| `--dry-run` | `flag` | `False` | Только проверить нестыковки без изменения базы данных |
| `--batch-size` | `int` | `500` | Размер пачки для `bulk_update` (оптимизация памяти и скорости) |
---
## 3. Планируемые команды (Roadmap)
- **`cleanup_storage`** — Удаление старых и низкорейтинговых картин (Smart Retention) при превышении квоты хранилища (использует рассчитанный `f_score`).
- **`cleanup_reports`** — Автоматическая изоляция или удаление картин со статусом `SHAMED` после истечения тайм-аута модерации.
+211 -86
View File
@@ -51,21 +51,21 @@
│ │ ├── 404.html │ │ ├── 404.html
│ │ └── 500.html │ │ └── 500.html
│ │ │ │
│ └── halftone/ # Django app │ └── hypn0_site/ # Основное Django app
│ ├── __init__.py │ ├── __init__.py
│ ├── urls.py │ ├── urls.py
│ ├── views.py # главная + генерация │ ├── views.py # главная + генерация
│ ├── forms.py # формы параметров │ ├── forms.py # формы параметров
│ ├── models.py # FeaturedSVG + votes │ ├── models.py # TbHypn0Item, TbVote, TbBlogPost
│ ├── services/ │ ├── services/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── halftone.py # ядро из main.py │ │ └── halftone.py # ядро генерации
│ ├── templates/ │ ├── templates/
│ │ └── halftone/ │ │ └── halftone/
│ │ └── index.html │ │ └── index.html
│ └── management/ │ └── management/
│ └── commands/ │ └── commands/
│ ├── cleanup_temp.py # очистка temp (14 дней) │ ├── cleanup_storage.py # очистка по рейтингу Smart Retention
│ └── cleanup_reports.py # удаление по жалобам │ └── cleanup_reports.py # удаление по жалобам
├── .gitea/workflows/ # CI/CD ├── .gitea/workflows/ # CI/CD
@@ -115,12 +115,12 @@ def generate_halftone_svg(
```python ```python
# Главная — GET → рендерим index.html # Главная — GET → рендерим index.html
# Генерация — POST (HTMX) → возвращаем SVG фрагмент # Генерация — POST (HTMX) → возвращаем SVG фрагмент
# Скачивание — GET /download/{temp_id} → отдаём файл + удаляем исходник # Скачивание — GET /download/{hash_id} → отдаём файл
# Просмотр публичного — GET /view/{hash} → отдаём SVG из temp/ # Просмотр публичного — GET /view/{hash_id} → отдаём страницу просмотра
# Галерея — GET /gallery → список FeaturedSVG # Галерея — GET /gallery → список TbHypn0Item
# Просмотр в галерее — GET /gallery/{hash} # Просмотр в галерее — GET /gallery/{hash_id}
# Report — POST /report/{hash} → increment reports_count # Vote/Report — POST /gallery/{hash_id}/vote → учет голоса в TbVote (Like/Claim)
# Promote (admin) — админ переносит SVG из temp → gallery # Promote (admin) — модератор повышает i_level (Level.LEVEL_2 или Level.IMMORTAL)
``` ```
### 3. Фронтенд (`hypn0/halftone/templates/halftone/index.html`) ### 3. Фронтенд (`hypn0/halftone/templates/halftone/index.html`)
@@ -251,26 +251,26 @@ pixels = image.quantize(colors=256) # или custom median-cut
``` ```
┌───────────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────────┐
🧠 HypnoSVG [Галерея] [О] │ hypn0 [Галерея] [О] │
├───────────────────┬───────────────────────────────────┤ ├───────────────────┬───────────────────────────────────┤
│ ◀ Настройки │ ▶ Превью │ │ ◀ Настройки │ ▶ Превью │
│ │ │ │ │ │
│ ┌──────────────┐ │ ┌─────────────────────────────┐ │ │ ┌──────────────┐ │ ┌─────────────────────────────┐ │
│ │ 📷 Загрузить │ │ │ <svg viewBox="..."> │ │ │ │ Загрузить │ │ │ SVG-КАРТИНКА │ │
│ └──────────────┘ │ │ <style>...</style> │ │ │ └──────────────┘ │ │ │ │
│ │ │ </svg> │ │ │ │ │ │ │
│ ─── Стиль ───── │ │ │ │ │ ─── Стиль ───── │ │ │ │
│ ┌─┐┌─┐┌─┐┌─┐ │ │ [♥ liked] [Скачать] │ │ │ ┌─┐┌─┐┌─┐┌─┐ │ │ [♥ liked -> в галерею] │ │
│ │●│┌─┐┌─┐┌─┐ │ └─────────────────────────────┘ │ │ │●│┌─┐┌─┐┌─┐ │ └─────────────────────────────┘ │
│ └─┘└─┘└─┘└─┘ │ [Поделиться] 🔗 │ └─┘└─┘└─┘└─┘ │ [Поделиться]
│ circle │ │ │ circle │ │
│ │ │ │ ┌─────────────────────────────┐
│ ─── Сетка ───── │ │ ─── Сетка ───── │ │ <svg viewBox="...">
│ ┌─┐┌─┐┌─┐┌─┐ │ │ ┌─┐┌─┐┌─┐┌─┐ │ │ <style>...</style>
│ │●│┌─┐┌─┐┌─┐ │ │ │●│┌─┐┌─┐┌─┐ │ │ </svg>
│ └─┘└─┘└─┘└─┘ │ │ └─┘└─┘└─┘└─┘ │
│ grid │ │ grid │ │ [В клипборд] [Скачать]
│ │ │ │ └─────────────────────────────┘
│ ─── Цвет ────── │ │ │ ─── Цвет ────── │ │
│ ┌─┐┌─┐┌─┐┌─┐ │ │ │ ┌─┐┌─┐┌─┐┌─┐ │ │
│ │●│┌─┐┌─┐┌─┐ │ │ │ │●│┌─┐┌─┐┌─┐ │ │
@@ -291,8 +291,8 @@ pixels = image.quantize(colors=256) # или custom median-cut
│ │ │ │ │ │
├───────────────────┴───────────────────────────────────┤ ├───────────────────┴───────────────────────────────────┤
│ ───────────── Галерея ───────────── │ │ ───────────── Галерея ───────────── │
│ [thumb] [thumb] [thumb] [thumb] ... │ [thumb] [thumb] [thumb] [thumb] ... │
│ [♥ 12] [♥ 8] [♥ 23] [♥ 5] │ [♥ 12] [♥ 8] [♥ 23] [♥ 5] │
└───────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────┘
``` ```
@@ -311,54 +311,166 @@ pixels = image.quantize(colors=256) # или custom median-cut
- Пагинация: 24 карточки на странице - Пагинация: 24 карточки на странице
- `GET /gallery/{hash}` — полноэкранный просмотр SVG с кнопками "Скачать" и "Поделиться" - `GET /gallery/{hash}` — полноэкранный просмотр SVG с кнопками "Скачать" и "Поделиться"
#### ВАЖНО: Оптимизация производительности рендеринга галереи (SVG-превью)
- **Проблема:** Десятки карточек с тысячами анимированных SVG-элементов могут перегрузить браузер клиента (CPU/GPU при одновременной перерисовке).
- **Решение для витрины галереи:**
- Карточки галереи выводятся через тег `<img src="..." loading="lazy">` (изолированный контекст рендеринга).
- Для предотвращения постоянного пожирания ресурсов анимация в SVG по умолчанию спит (`animation-play-state: paused`) и оживает только при наведении (`:hover { animation-play-state: running; }`).
- Дополнительно на контейнерах карточек используется CSS-свойство `content-visibility: auto` для отключения рендера невидимых вне экрана карточек.
- **Выдача пользователю («Копировать» и «Скачать»):**
- Пользователю всегда отдается «чистый» полнофункциональный SVG со стандартно запущенной анимацией (без ограничений `animation-play-state: paused`).
- На бэкенде/сервисе при сохранении для галереи модификатор паузы/ховера может вшиваться по умолчанию либо подставляться/вырезаться на лету при выдаче пользователю.
**CSS:** **CSS:**
- Dev: Tailwind CDN (`<script src="https://cdn.tailwindcss.com"></script>`) + Purge content в конфиге - Dev: Tailwind CDN (`<script src="https://cdn.tailwindcss.com"></script>`) + Purge content в конфиге
- Prod: `collectstatic``whitenoise` обслуживает скомпилированную статику - Prod: `collectstatic``whitenoise` обслуживает скомпилированную статику
HTMX: форма с `hx-post`, `hx-encoding="multipart/form-data"` → SVG вставляется в `#preview`. HTMX: форма с `hx-post`, `hx-encoding="multipart/form-data"` → SVG вставляется в `#preview`.
### 4. Политика хранения ### 4. Политика хранения, приватность и модели
**Исходные изображения:** удаляются сразу после генерации (не хранятся вообще). #### 4.1. Принципы приватности, Zero-PII и учет голосов
- **Полное отсутствие регистрации для посетителей:** Пользователи не создают аккаунты, не вводят email, пароли или личные данные. Единственный пользователь в `auth.User` — администратор сайта для входа в Django Admin.
- **Исходные растровые изображения (JPG/PNG/WEBP):** Обрабатываются исключительно в оперативной памяти (через буфер `BytesIO`) и **не сохраняются на диск сервера вообще**.
- **Куки-согласие и идентификация устройства (Cookie `hypn0_vid`):**
- При согласии с правилами («Подчиниться Гипножабе!» в `allow-tracking.html`) в браузере генерируется случайный UUIDv4 (`vid`), который сохраняется в долгоживущую анонимную куку `hypn0_vid` (`Max-Age=2 года`, `SameSite=Lax`) и дублируется в `localStorage`.
- **Единый статус согласия и идентификации:**
- Наличие куки `hypn0_vid` одновременно означает:
1. Посетитель дал согласие на условия / трекинг (`ALLOW_TRACKING = 'hypn0_vid' in request.COOKIES` в `context_processors.py`).
2. У бэкенда есть анонимный `visitor_uuid` для формирования цифрового отпечатка `s_fingerprint` в `TbVote`.
- **Правила для пользователей без подчинения Гипножабе (без куки `hypn0_vid`):**
- Пользователь **может** загружать изображение, экспериментировать с настройками, генерировать и скачивать SVG-файл на свое устройство (чистая локальная работа).
- Пользователь **НЕ может** публиковать картину в галерею или голосовать за чужие работы:
- Публикация картины на сервер требует немедленного создания авторской записи `TbVote(i_direction=Direction.AUTHOR)` для закрепления авторства, защиты от самолайка и стартового рейтинга.
- Без согласия и UUID сформировать `s_fingerprint` невозможно $\rightarrow$ кнопка «Опубликовать в галерею» и лайки заблокированы в интерфейсе (с всплывающим требованием подчиниться Гипножабе) и отклоняются бэкендом (HTTP 403).
- **Учет голосов, защита от накрутки и определение авторства (`TbVote`):**
- **Бэкенд:** Голоса учитываются анонимно через модель `TbVote` со связкой `(k_item, s_fingerprint)`. Поле `s_fingerprint` формируется как необратимый хэш: `sha256(visitor_uuid + SECRET_KEY)`. Это надежно разделяет разные компьютеры в одной корпоративной сети/NAT и не содержит открытых ПДн (Zero-PII).
- **Направление голоса (`i_direction` / `Direction`):**
- `Direction.LIKE` (`+1`) — обычный лайк посетителя.
- `Direction.CLAIM` (`-2`) — жалоба / клейм на неподобающий контент.
- `Direction.AUTHOR` (`+2`) — голос создателя картины.
- **Идентификация автора через `Direction.AUTHOR`:**
- При генерации картины в `TbVote` автоматически создается запись с `i_direction=Direction.AUTHOR`.
- Это исключает необходимость хранить отдельное поле `s_author_fingerprint` в `TbHypn0Item` (нет дублирования данных).
- Благодаря `UniqueConstraint(fields=["k_item", "s_fingerprint"])` автор автоматически защищен от «самолайка» (повторный лайк со своего браузера не пройдет).
- Свежая генерация сразу получает стартовый вес (`+2`), предотвращающий мгновенное удаление при низком `f_score`.
- **Фронтенд:** Для быстродействия UI и исключения лишней нагрузки список лайкнутых картин дублируется локально на клиенте в **`localStorage`** (`hypn0_votes`). На базе Alpine.js выполняется мгновенная индикация проголосованных работ.
- **Соответствие GDPR / 152-ФЗ:** Персональные данные не собираются и не хранятся.
**Публичные SVG (опционально, по желанию пользователя):** #### 4.2. Генерация названий (AI & Fallback Title)
``` У каждой сгенерированной картины обязательно должно быть название (title):
public/media/temp/{hash_16символов}.svg - **Основной способ:** Вызов через **OpenRouter API** с отправкой сильно сжатой миниатюры (thumbnail) исходного изображения в мультимодальную LLM (VLM) для распознавания сюжета и генерации забавного гипно-названия (в стиле Гипножабы/мемов).
``` - **Резервный способ (Fallback):** Если API OpenRouter недоступен, закончились квоты или таймаут — название генерируется локально из случайного набора гипнотических слов, эпитетов и фраз («Мерцающий транс #42», «Глаз Гипножабы», «Астральный вихрь» и т.п.).
- TTL: **14 дней**
- URL: `/view/{hash}` — прямая ссылка, без индексации
- Автоматическая очистка: `manage.py cleanup_temp --older-than 1209600` → crontab
**Галерея (FeaturedSVG):** #### 4.3. Стратегия хранения и умной очистки (Smart Retention)
- Администратор/promote: лучшие SVG переезжают из `temp/` в `gallery/` Проект не выступает постоянным файлообменником или бесплатным CDN. Пользователь скачивает сгенерированный SVG для личного использования.
- Хранятся вечно (лучшая коллекция халфтонов) Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности («гравитации»), а не слепой таймер:
- Прямая ссылка: `/gallery/{hash}`
- `/gallery` — страница галереи со всеми (пагинация) $$\text{Score} = \frac{\text{Likes} + \text{Bonus}_{\text{moderator}}}{(\text{Age in hours} + 2)^\gamma}$$
- **Квота хранилища:** Задается жесткий лимит объема (например, 500 МБ или N тысяч файлов).
- **Уровни хранения (`i_level` / `TbHypn0Item.Level`):**
- `CANDIDATE` (`LVL_CANDIDATE`): свежая генерация, защита от удаления на начальный период.
- `LEVEL_1` (`LVL_MIN_VOTES`): получен минимум лайков («Лёгкий транс»).
- `LEVEL_2` (`LVL_MODERATED`): проверено и одобрено («Одобрено Мозговым Слизнем»), удаление возможно при низком `f_score`.
- `IMMORTAL` (`LVL_LOCK_FOR_DELETION`): полный иммунитет к удалению даже при низком рейтинге («Глубокий транс»).
- **Очистка (`manage.py cleanup_storage`):** При превышении квоты диска удаляются файлы с наименьшим `f_score` (не затрагивая защищенные уровни).
#### 4.4. SEO, шеринг и коммерциализация
- **Вшивание ссылки в SVG (Zero-cost Branding):** В скачиваемый SVG на этапе генерации добавляется мета-комментарий со ссылкой на генератор (`<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->` и `<metadata>`).
- **Страница просмотра (`/v/<hash>`):** Для внешнего шеринга и индексации поисковиками генерируется легкая HTML-страница с OpenGraph разметкой и кнопкой «Открыть настройки в генераторе».
- **Возможность промо/коммерциализации:** Поддержка опциональных полей (`s_promo_url`, `s_promo_title`, `i_promo_clicks`).
#### 4.5. Блог, статические страницы и типографика (HTML + `etpgrf`)
- **Отказ от конвертации Markdown «на лету»:** Вместо оверхеда с парсингом Markdown статьи и инфо-страницы хранятся напрямую в формате **HTML**.
- **Очистка HTML в админке:** При вводе/сохранении в Django Admin контент проходит валидацию и санитизацию (очистку от вредоносного или мусорного HTML/инлайн-стилей).
- **Типографирование через `etpgrf`:** Для безупречной верстки текста (неразрывные пробелы, висячая пунктуация, кавычки-елочки, тире) используется библиотека типографирования **`etpgrf`**.
#### 4.6. Архитектура моделей (Венгерская нотация и типизация)
В моделях используется венгерская нотация префиксов полей (`s_` — string/text/url, `i_` — integer, `f_` — float, `j_` — json, `k_` — foreign key, `d_` — datetime, `is_` — boolean, `file_` — file), а также оптимизированные типы первичных ключей (`SmallAutoField`, `AutoField`, `BigAutoField`).
**Модель `FeaturedSVG`:**
```python ```python
class FeaturedSVG(models.Model): class TbHypn0Item(models.Model):
svg_hash = models.CharField(max_length=16, unique=True) """
original_temp_path = models.CharField(max_length=512) Единая модель для генераций, публичных шеров и витрины галереи.
gallery_path = models.CharField(max_length=512) """
params_json = models.JSONField(default=dict) class Level(models.IntegerChoices):
votes = models.PositiveIntegerField(default=0) CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация
promoted_at = models.DateTimeField(auto_now_add=True) LEVEL_1 = LVL_MIN_VOTES, 'Level 1: Лёгкий транс' # Есть первичные голоса
promoted_by = models.ForeignKey('auth.User', null=True, blank=True) LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Модерировано
reports_count = models.PositiveIntegerField(default=0) IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Иммунитет к удалению
id = models.AutoField(primary_key=True, verbose_name="ID") # 4 байта, до ~2.14 млрд
s_hash_id = models.CharField(max_length=16, unique=True, verbose_name="ID-Хэш")
s_title = models.CharField(max_length=255, verbose_name="Заголовок")
file_svg = models.FileField(upload_to="svg/%Y/%m/", verbose_name="SVG-файл")
i_file_size = models.PositiveIntegerField(default=0, verbose_name="Размер файла (байт)")
# Метаданные и снимок параметров алгоритма генерации
j_metadata = models.JSONField(default=dict, blank=True, verbose_name="Метаданные")
# Популярность, модерация и рейтинг Smart Retention
i_likes_count = models.PositiveIntegerField(default=0, db_index=True, verbose_name="Лайки")
i_views_count = models.PositiveIntegerField(default=0, verbose_name="Просмотры")
i_claims_count = models.PositiveIntegerField(default=0, verbose_name="Жалобы")
f_score = models.FloatField(default=0.0, db_index=True, verbose_name="Рейтинг Smart Retention")
i_level = models.IntegerField(choices=Level.choices, default=Level.CANDIDATE, verbose_name="Уровень хранения")
is_public = models.BooleanField(default=True, verbose_name="Публичный доступ")
# Промо-блок (на перспективу)
s_promo_url = models.URLField(blank=True, default="", verbose_name="Промо-ссылка")
s_promo_title = models.CharField(max_length=100, blank=True, default="", verbose_name="Бейдж/автор")
i_promo_clicks = models.PositiveIntegerField(default=0, verbose_name="Клики по промо")
d_created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="Дата создания")
d_updated_at = models.DateTimeField(auto_now=True, db_index=True, verbose_name="Дата обновления")
class TbVote(models.Model):
"""
Анонимный учет голосов (лайков, жалоб, авторства) без сохранения ПДн.
"""
class Direction(models.IntegerChoices):
LIKE = VOTE_LIKE, 'Like (+1)'
CLAIM = VOTE_CLAIM, 'Claim (-2)'
AUTHOR = VOTE_AUTHOR, 'Author Like (+2)'
id = models.BigAutoField(primary_key=True, verbose_name="ID") # 8 байт, до ~9×10¹⁸
k_item = models.ForeignKey(TbHypn0Item, on_delete=models.CASCADE, related_name="votes", verbose_name="Картина")
i_direction = models.IntegerField(choices=Direction.choices, default=Direction.LIKE, verbose_name="Направление")
s_fingerprint = models.CharField(
max_length=64,
db_index=True,
verbose_name="Хэш устройства",
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки"
)
d_created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата голосования")
class Meta:
constraints = [
models.UniqueConstraint(fields=["k_item", "s_fingerprint"], name="unique_item_fingerprint_vote")
]
class TbBlogPost(models.Model):
"""
Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
"""
id = models.SmallAutoField(primary_key=True, verbose_name="ID") # 2 байта, до 32 767 записей
s_title = models.CharField(max_length=255, help_text="HTML-заголовок, очищенный и типографированный etpgrf")
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-слаг")
s_teaser = models.TextField(max_length=1024, blank=True, help_text="HTML-тизер, очищенный и типографированный etpgrf")
s_content = models.TextField(help_text="HTML-контент статьи, очищенный и типографированный etpgrf")
f_cover_img = models.ImageField(upload_to="blog/covers/%Y/", blank=True, null=True, verbose_name="Обложка")
is_published = models.BooleanField(default=False, verbose_name="Опубликовано")
d_published_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name="Дата публикации")
d_created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
d_updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
``` ```
**Report / жалоба:**
- `/report/{hash}` — кнопка "Пожаловаться" на странице просмотра
- Просто счётчик `reports_count` в модели (или просто scan для ручного удаления)
- После жалобы — админ проверяет и удаляет вручную
**Публичный режим:**
Все генерации публичны:
- Любой SVG доступен к скачиванию
- SVG показываются в браузере по `temp/` или `/gallery`, но доступны только `/hypn0/` с hash-именем
- В браузере, показывается "Поделиться ссылкой"
## Шаги реализации ## Шаги реализации
### Шаг 1. Структура каталогов ### Шаг 1. Структура каталогов
@@ -446,32 +558,35 @@ CLI больше не нужен.
## Файлы для создания (итого) ## Файлы для создания (итого)
| Файл | Назначение | | Файл | Назначение |
|---------------------------------------------------------|------------------------| |-----------------------------------------------------------|---------------------------------|
| `hypn0/manage.py` | Django CLI | | `hypn0/manage.py` | Django CLI |
| `hypn0/hypn0/__init__.py` | Settings pkg | | `hypn0/hypn0/__init__.py` | Settings pkg |
| `hypn0/hypn0/settings.py` | Конфиг | | `hypn0/hypn0/settings.py` | Конфиг |
| `hypn0/hypn0/urls.py` | URL routing | | `hypn0/hypn0/urls.py` | URL routing |
| `hypn0/hypn0/wsgi.py` | WSGI entry | | `hypn0/hypn0/wsgi.py` | WSGI entry |
| `hypn0/hypn0/templates/base.html` | Базовый шаблон | | `hypn0/hypn0/templates/base.html` | Базовый шаблон |
| `hypn0/halftone/__init__.py` | App pkg | | `hypn0/hypn0_site/__init__.py` | App pkg |
| `hypn0/halftone/urls.py` | URL app | | `hypn0/hypn0_site/urls.py` | URL app |
| `hypn0/halftone/views.py` | Views | | `hypn0/hypn0_site/views.py` | Views |
| `hypn0/halftone/forms.py` | Forms | | `hypn0/hypn0_site/forms.py` | Forms |
| `hypn0/halftone/models.py` | FeaturedSVG | | `hypn0/hypn0_site/models.py` | TbHypn0Item, TbVote... |
| `hypn0/halftone/services/halftone.py` | Ядро | | `hypn0/hypn0_site/services/halftone.py` | Ядро |
| `hypn0/halftone/templates/halftone/index.html` | Главная | | `hypn0/hypn0_site/templates/halftone/index.html` | Главная |
| `hypn0/halftone/management/commands/cleanup_temp.py` | Очистка temp (14 дней) | | `hypn0/hypn0_site/management/commands/rescore.py` | Скоринг и детекция аномалий |
| `hypn0/halftone/management/commands/cleanup_reports.py` | Очистка по жалобам | | `hypn0/hypn0_site/management/commands/rehash.py` | Синхронизация s_hash_id с солью |
| `public/static/css/site.css` | Стили | | `hypn0/hypn0_site/management/commands/cleanup_storage.py` | Очистка SmartRetention |
| `public/static/js/site.js` | JS | | `hypn0/hypn0_site/management/commands/cleanup_reports.py` | Очистка по жалобам |
| `.env.sample` | Env template | | `_blueprint/management-commands.md` | Документация CLI-команд |
| `Dockerfile` | Сборка | | `public/static/css/site.css` | Стили |
| `docker-compose.local.yml` | Dev | | `public/static/js/site.js` | JS |
| `docker-compose.prod.yml` | Prod | | `.env.sample` | Env template |
| `config/nginx/hypn0-app--external-nginx.conf` | Nginx | | `Dockerfile` | Сборка |
| `.gitea/workflows/docker-publish.yaml` | CI/CD | | `docker-compose.local.yml` | Dev |
| `database/.gitignore` | Игнор БД | | `docker-compose.prod.yml` | Prod |
| `config/nginx/hypn0-app--external-nginx.conf` | Nginx |
| `.gitea/workflows/docker-publish.yaml` | CI/CD |
| `database/.gitignore` | Игнор БД |
## Файлы для модификации ## Файлы для модификации
@@ -508,3 +623,13 @@ CLI больше не нужен.
- 100–200 пользователей/день на генерацию — без проблем - 100–200 пользователей/день на генерацию — без проблем
- Сотни просмотров галереи — без проблем - Сотни просмотров галереи — без проблем
- 500+ генераций/день → вертикальное масштабирование (2 CPU / 2 GB) - 500+ генераций/день → вертикальное масштабирование (2 CPU / 2 GB)
## Долгосрочный Roadmap / Отдаленные TODO (Post-MVP)
### 1. Оптимизация веса SVG через 4-символьную HEX-палитру (`#RGB`)
- **Цель:** Максимальная компактность генерируемого SVG и инлайновых стилей/анимаций.
- **Идея:** Ограничить цветовую палитру короткими 4-символьными HEX-кодами (`#RGB`, например `#CD0`, `#F00`, `#08F` — всего $16^3 = 4096$ цветов или квантованная выборка из 256 цветов).
- **Эффект:** Экономия до 3–4 байт на каждом элементе/переменной цвета в SVG, что при тысячах точек дает существенное уменьшение веса итогового файла.
- **UI/Фронтенд:**
- Реализация компактного интерфейса выбора из ограниченной палитры без раздувания HTML-кода (через динамический генератор палитры на Alpine.js, цветовую сетку с шагом оттенков или компактный модал/попап).
- Округление/квантование цвета на клиенте и бэкенде до ближайшего 3-значного hex-значения.
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "hypn0-codemirror6",
"version": "0.1.0",
"description": "CodeMirror build for Hypn0 Django Admin",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/editor.js --bundle --format=iife --minify --outfile=${CM6_OUTPUT_DIR:-../../public/static/codemirror}/editor.js"
},
"devDependencies": {
"@babel/runtime": "7.29.7",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.1",
"@codemirror/language": "6.12.3",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@uiw/codemirror-theme-solarized": "4.25.10",
"esbuild": "0.28.0"
}
}
+10
View File
@@ -93,6 +93,7 @@ TEMPLATES = [
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.template.context_processors.csrf',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'hypn0_site.context_processors.site_context', 'hypn0_site.context_processors.site_context',
@@ -199,6 +200,15 @@ STORAGES = {
}, },
} }
# СПЕЦИФИЧНЫЕ ДЛЯ ПРОЕКТА НАСТРОЙКИ
# ====== HASHIDS настройка для s_hash_id ======
# Криптографическое кодирование ID svg-файлов генерации в компактные, необратимые коды.
# Благодаря hashids невозможно восстановить исходный ID из кода,
# даже зная алгоритм (в отличие от простого XOR).
HASHIDS_SALT = env('HASHIDS_SALT', default='your-secret-salt-change-me')
HASHIDS_MIN_LENGTH = env.int('HASHIDS_MIN_LENGTH', default=6)
if DEBUG: if DEBUG:
# В деве оставляем стандартную отдачу статики Django без WhiteNoise. # В деве оставляем стандартную отдачу статики Django без WhiteNoise.
STORAGES['staticfiles'] = { STORAGES['staticfiles'] = {
+517 -2
View File
@@ -1,3 +1,518 @@
from django.contrib import admin # Кастомная конфигурация Django Admin для Hypn0.
# Регистрируем модели с удобным интерфейсом.
# Register your models here. import etpgrf
import random
import re
import pytils
from django import forms
from django.contrib import admin
from django.forms import Textarea
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.http import HttpRequest
from bs4 import BeautifulSoup
from html import unescape
from .models import (
TbHypn0Item,
TbVote,
TbBlogPost
)
# ============================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================================
def clean_html_to_plain_text(html_content: str) -> str:
"""
Очищает HTML-контент от тегов, скриптов, стилей и декодирует HTML-сущности.
Возвращает чистый читаемый текст.
"""
if not html_content:
return ""
soup = BeautifulSoup(html_content, "html.parser")
# Скрипты, стили и форматирование вырезаем целиком
for tag in soup(["script", "style", "noscript", "code", "kbd", "pre"]):
tag.decompose()
return unescape(soup.get_text()).strip()
# ============================================================================
# МИКСИНЫ ДЛЯ АДМИНКИ
# ============================================================================
class RequestInFormMixin(admin.ModelAdmin):
"""
Миксин для передачи request объекта в форму.
Используется когда форма нуждается в доступе к request для проверки POST параметров
или другой информации о текущем HTTP-запросе.
Переопределяет get_form() и передает request в __init__ формы через kwargs.
"""
def get_form(self, request: HttpRequest, obj=None, **kwargs):
"""
Переопределяем get_form чтобы передать request в форму.
Создаем оборачивающий класс который передаст request в __init__.
"""
FormClass = super().get_form(request, obj, **kwargs)
# Сохраняем request в замыкании для доступа в классе
request_ref = request
class FormWithRequest(FormClass):
"""Оборачивающий класс, который передает request при инстанцировании"""
def __init__(form_instance, *args, **init_kwargs):
# Добавляем request в kwargs перед вызовом __init__ родителя
init_kwargs['request'] = request_ref
super().__init__(*args, **init_kwargs)
return FormWithRequest
class CodeMirrorFormMixin(forms.ModelForm):
"""
Миксин для форм с поддержкой CodeMirror редактора.
Предоставляет:
- Готовый Media класс с CSS и JS для CodeMirror
- Helper метод setup_codemirror_field() для конфигурации полей
- Базовые атрибуты для активации CodeMirror
Использование:
class MyForm(CodeMirrorFormMixin):
class Meta:
model = MyModel
fields = (...)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup_codemirror_field('field_name', 'json', 'codemirror-width-l')
"""
# ===== MEDIA КЛАСС ДЛЯ CODEMIRROR =====
class Media:
"""Подключаем CSS и JS для CodeMirror редактора"""
css = {
'all': ('codemirror/codemirror-styles.css',) # Стили для CodeMirror
}
js = (
'codemirror/editor.js', # Основной CodeMirror
'codemirror/codemirror-patch.js', # Патч для управления высотой/шириной
)
# ===== БАЗОВЫЕ АТРИБУТЫ CODEMIRROR =====
CODEMIRROR_ATTRS_BASE = {
'data-codemirror-editor': '1',
'data-width': '100%', # Ширина для патча (100% займет полную ширину)
}
def setup_codemirror_field(self, field_name: str, language: str = 'text', css_class: str = 'codemirror-width-l'):
"""
Конфигурирует поле для использования CodeMirror редактором.
Применяет Textarea виджет с нужными атрибутами и CSS классами для CodeMirror.
Args:
field_name: Имя поля в форме (например, 's_label')
language: Язык для подсветки синтаксиса (text, json, html, url и т.д.)
css_class: CSS класс для управления размерами (codemirror-width-s, codemirror-width-l и т.д.)
Пример:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup_codemirror_field('s_label', 'text', 'codemirror-width-xl codemirror-no-lines')
self.setup_codemirror_field('j_metadata', 'json', 'codemirror-width-l')
"""
# Собираем атрибуты для поля
attrs = {
**self.CODEMIRROR_ATTRS_BASE,
'data-language': language,
'class': css_class,
}
# Применяем Textarea виджет с атрибутами
self.fields[field_name].widget = Textarea(attrs=attrs)
@admin.register(TbHypn0Item)
class TbHypn0ItemAdmin(admin.ModelAdmin):
"""
Панель управления гипно-картинами (модерация, просмотр статистики, редактирование).
Создание новых картин через админку заблокировано (создание только на фронтенде).
"""
list_display = (
"id",
"s_hash_id",
"title_preview",
"i_level",
"i_likes_count",
"i_claims_count",
"i_views_count",
"f_score",
"is_public",
"d_created_at",
)
list_display_links = ("id", "s_hash_id", "title_preview")
list_filter = ("i_level", "is_public", "d_created_at")
search_fields = ("s_hash_id", "s_title", "s_promo_title", "s_promo_url")
readonly_fields = (
"id",
"s_hash_id",
"i_file_size",
"i_likes_count",
"i_views_count",
"i_claims_count",
"i_promo_clicks",
"d_created_at",
"d_updated_at",
"svg_preview",
)
fieldsets = (
(
"Основная информация",
{
"fields": (
"id",
"s_hash_id",
"s_title",
"file_svg",
"svg_preview",
"i_file_size",
"is_public",
)
},
),
(
"Модерация и Smart Retention",
{
"fields": (
"i_level",
"f_score",
),
"description": "Уровень модерации и рейтинг 'гравитации' для ротации и очистки хранилища.",
},
),
(
"Метрики популярности",
{
"fields": (
"i_likes_count",
"i_claims_count",
"i_views_count",
),
},
),
(
"Промо-блок (Спонсоры и Авторы)",
{
"fields": (
"s_promo_url",
"s_promo_title",
"i_promo_clicks",
),
"classes": ("collapse",),
},
),
(
"Метаданные генерации",
{
"fields": ("j_metadata",),
"classes": ("collapse",),
},
),
(
"Временные метки",
{
"fields": ("d_created_at", "d_updated_at"),
"classes": ("collapse",),
},
),
)
def has_add_permission(self, request):
# Создание картин возможно только с фронтенда (требуется обработка растра и согласие пользователя)
return False
@admin.display(description="Название (HTML)")
def title_preview(self, obj):
if obj and obj.s_title:
return mark_safe(unescape(obj.s_title))
return ""
@admin.display(description="Превью SVG")
def svg_preview(self, obj):
if obj.file_svg:
return format_html(
'<div style="max-width: 250px; max-height: 250px; background: #1e293b; padding: 8px; border-radius: 8px;">'
'<img src="{}" style="max-width: 100%; max-height: 230px;" />'
'</div>',
obj.file_svg.url,
)
return "Нет файла"
# ----
# АДМИНКА ЛАЙКОВ/ДИСЛАЙКОВ
@admin.register(TbVote)
class TbVoteAdmin(admin.ModelAdmin):
"""
Просмотр анонимного журнала голосов и отпечатков (только для чтения).
"""
list_display = ("id", "k_item", "i_direction", "fingerprint_short", "d_created_at")
list_filter = ("i_direction", "d_created_at")
search_fields = ("s_fingerprint", "k_item__s_hash_id", "k_item__s_title")
readonly_fields = ("id", "k_item", "i_direction", "s_fingerprint", "d_created_at")
ordering = ("-d_created_at",)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.display(description="Отпечаток (хэш)")
def fingerprint_short(self, obj):
return f"{obj.s_fingerprint[:12]}..."
# ----
# АДМИНКА БЛОГА
# Кастомная форма
class BlogPostAdminForm(CodeMirrorFormMixin):
"""
Кастомная форма для админки потов в блог (TbBlogPost).
Добавляет виджеты CodeMirror для текстовых полей.
"""
# Виртуальные поля для настройки типографа
etp_enable = forms.BooleanField(
label="Включить типограф",
initial=False,
required=False,
help_text="Включить автоматическую типографику для HTML полей (заголовок, тизер, контент)&nbsp;&nbsp;&nbsp;"
)
etp_language = forms.ChoiceField(
label="Язык типографики",
choices=[('ru', 'Русский'), ('en', 'English'), ('ru,en', 'Ru + En')],
initial='ru',
required=False
)
etp_quotes = forms.BooleanField(
label="Кавычки",
initial=True,
required=False,
help_text="Заменять кавычки<br/>(«ёлочки» для русского, “лапки” для английского)&nbsp;&nbsp;&nbsp;"
)
etp_hyphenation = forms.BooleanField(
label="Расставлять переносы",
initial=True,
required=False,
help_text="Расставлять мягкие переносы (&amp;shy;)&nbsp;&nbsp;&nbsp;<br/>"
"в словах длиннее <b>14</b> символов&nbsp;&nbsp;&nbsp;"
)
etp_sanitize = forms.BooleanField(
label="Очистка HTML",
initial=False,
required=False,
help_text="Удалять весь HTML из исходного текста&nbsp;&nbsp;&nbsp;"
)
etp_hanging_punctuation = forms.BooleanField(
label="Висячая пунктуация",
initial=False,
required=False,
help_text="Выносить пунктуацию в начало строк<br/>&nbsp;&nbsp;&nbsp;"
"(только для заголовков... для тизера и контента отключается автоматически)&nbsp;&nbsp;&nbsp;"
)
etp_mode = forms.ChoiceField(
label="Режим вывода",
choices=[('mixed', 'Смешанный (Mixed)'), ('unicode', 'Юникод (Unicode)'), ('mnemonic', 'Мнемоники')],
initial='mixed',
required=False,
help_text="Формат спецсимволов (например, кавычек, тире, многоточий) в&nbsp;HTML: смешанный, юникод"
"&nbsp;или мнемоники&nbsp;&nbsp;&nbsp;"
)
class Meta:
model = TbBlogPost
fields = (
# Виртуальные поля для настройки типографа
'etp_enable', 'etp_language', 'etp_quotes', 'etp_hyphenation', 'etp_sanitize',
'etp_hanging_punctuation', 'etp_mode',
# Остальные поля модели TbArticle
's_title', 'slug', 's_teaser', 's_content', 'f_cover_img',
'is_published', 'd_published_at',
)
def __init__(self, *args, **kwargs):
"""
При инициализации формы подгружаем CodeMirror редактор.
Получаем request из kwargs, переданных из get_form_kwargs в AdminClass.
"""
# Извлекаем request из kwargs если он есть
self.request = kwargs.pop('request', None)
super().__init__(*args, **kwargs)
# Конфигурируем поля для CodeMirror
self.setup_codemirror_field('s_title', language='text',
css_class='codemirror-width-l codemirror-no-lines')
self.setup_codemirror_field('slug', language='text',
css_class='codemirror-width-l codemirror-no-lines')
self.setup_codemirror_field('s_teaser', language='html',
css_class='codemirror-width-xl codemirror-min-height-5')
self.setup_codemirror_field('s_content', language='html',
css_class='codemirror-width-xl codemirror-min-height-10')
@admin.register(TbBlogPost)
class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin):
"""
Управление статьями блога, документацией и инфо-страницами (HTML + etpgrf).
"""
form = BlogPostAdminForm
list_display = ("id", "article_thumbnail", "s_title_display", "slug", "is_published", "d_published_at", "d_created_at")
list_display_links = ("id", "s_title_display")
list_filter = ("is_published", "d_published_at", "d_created_at")
search_fields = ("s_title", "slug", "s_teaser", "s_content")
prepopulated_fields = {"slug": ("s_title",)}
readonly_fields = ("id", "d_created_at", "d_updated_at")
fieldsets = (
("Атрибуты публикации", {
"fields": ("is_published", "slug", "d_published_at", )
}),
("Основные поля", {
"fields": ("s_title", "f_cover_img", "s_teaser", "s_content", ),
"description": "В полях допускается HTML, поддерживается чистая верстка, настройки типографа etpgrf в"
" следующем блоке.",
}),
('Типограф', {
'fields': (('etp_enable',), ('etp_language', 'etp_mode'),
('etp_quotes', 'etp_hyphenation', 'etp_sanitize', 'etp_hanging_punctuation')),
# 'classes': ('collapse',),
'description': 'Типограф применяется при сохранении и срабатывает на HTML-поля (ЗАГОЛОВОК, ТИЗЕР СТАТЬИ'
' и СТАТЬЯ). Если выключить — HTML будет сохранен без изменений.',
}),
("Системные даты", {
"fields": ("id", "d_created_at", "d_updated_at", ),
"classes": ("collapse",),
}),
)
@admin.display(description="Обложка")
def article_thumbnail(self, obj):
"""
Отображает миниатюру изображения статьи в списке (аккуратный квадрат 36x36 px).
Работает со стандартным ImageField (f_cover_img) без внешних библиотек.
"""
if obj and obj.f_cover_img:
try:
return format_html(
'<img src="{}" width="20" height="20" style="border: 1px solid silver;" title="{}" alt="" />',
obj.f_cover_img.url,
obj.s_title,
)
except Exception:
return mark_safe('<span style="color: #999">(ошибка)</span>')
return mark_safe(
'<img width="20" height="20" style="border: 1px solid silver; background: #90909060;" title="Нет картинки" alt="" />'
)
def save_model(self, request, obj, form, change):
"""
Переопределяем save_model для применения:
1. Tипографа etpgrf для полей s_title, s_teaser и s_content
2. Генерации Slug c транслитерацией
Args:
request: HTTP-запрос (содержит info о пользователе)
obj: инстанция TbArticle для сохранения
form: валидированная форма
change: True если редактирование, False если создание
"""
# 1. ТИПОГРАФ ETPGRF
# Проверяем, включен ли типограф
if form.cleaned_data.get('etp_enable', True):
# Получаем все настройки из формы
langs = form.cleaned_data.get('etp_language', 'ru').split(',')
# 1.1. LayoutProcessor: включаем layout с базовыми настройками
layout_option = etpgrf.LayoutProcessor(
langs=langs,
process_initials_and_acronyms=True,
process_units=True
)
# 1.2. Hyphenator (переносы слов)
hyphenation_option = False
if form.cleaned_data.get('etp_hyphenation', True):
hyphenation_option = etpgrf.Hyphenator(
langs=langs,
max_unhyphenated_len=14
)
# 1.3. Sanitizer (очистка HTML перед типографированием)
# Режимы: 'html' (удаляет все теги), 'etp' (только висячая пунктуация), None/False (ничего не делает)
if form.cleaned_data.get('etp_sanitize', True):
sanitizer_option = 'html' # Удаляет все HTML-теги
else:
sanitizer_option = False # Санитайзер отключен
# 1.4. Базовые настройки типографа (используются для всех полей)
base_options = {
'langs': langs,
'process_html': True,
'quotes': form.cleaned_data.get('etp_quotes', True),
'layout': layout_option,
'unbreakables': True,
'hyphenation': hyphenation_option,
'sanitizer': sanitizer_option,
'symbols': True,
'mode': form.cleaned_data.get('etp_mode', 'mixed'),
}
# 1.5. Для заголовков: висячая пунктуация может быть включена
options_title = {
**base_options,
'hanging_punctuation': form.cleaned_data.get('etp_hanging_punctuation', True),
}
t_title = etpgrf.Typographer(**options_title)
if obj.s_title:
obj.s_title = t_title.process(obj.s_title)
# 1.6. Для тизера и контента: висячая пунктуация всегда отключена
options_body = {
**base_options,
'hanging_punctuation': False,
}
t_body = etpgrf.Typographer(**options_body)
if obj.s_teaser:
obj.s_teaser = t_body.process(obj.s_teaser)
if obj.s_content:
obj.s_content = t_body.process(obj.s_content)
# 2. SLUG
if not obj.slug:
# 2.0. Если вдруг нет заголовка, то генерируем случайный slug
if not obj.s_title:
obj.slug = f"title-{random.randint(1, 4095):03x}"
else:
# 2.1. Очищаем текст от HTML и спецсимволов через вспомогательную функцию
plain_title = clean_html_to_plain_text(obj.s_title)
# 2.2. Транслитерируем и создаем slug (pytils подходит для русского)
obj.slug = pytils.translit.slugify(plain_title) if plain_title else ""
# 2.3. Нормализуем множественные дефисы, удаляем дефисы в начале/конце
obj.slug = re.sub(pattern=r"-+", repl="-", string=obj.slug).strip("-")
# 2.4. Если все еще нет slug (например заголовок из спец-символов) — генерируем
obj.slug = obj.slug or f"title-{random.randint(1, 4095):03x}"
# 3. Вызываем родительский save_model который вызовет obj.save()
super().save_model(request, obj, form, change)
@admin.display(description="Заголовок")
def s_title_display(self, obj):
# Отображаем HTML-заголовок статьи как безопасный HTML
if obj and obj.s_title:
return mark_safe(unescape(obj.s_title))
return ""
+6 -4
View File
@@ -1,10 +1,12 @@
from django.conf import settings from django.conf import settings
from django.http import HttpRequest from django.http import HttpRequest
# Имя куки, которая ставится клиенту, когда он СОГЛАСИЛСЯ на отслеживание # Имя куки с UUID устройства посетителя. Устанавливается при согласии
# (Google Tag Manager, Яндекс.Метрика и т.п.). Если кука присутствует — # с условиями ("Подчиниться Гипножабе!").
# предупреждение о трекинге больше не показываем. # Одновременно служит:
ALLOW_TRACKING_COOKIE_NAME = "allow_tracking" # 1) Флагом согласия на отслеживание (ALLOW_TRACKING).
# 2) Анонимным идентификатором устройства для учета голосов (TbVote) без сбора ПДн.
ALLOW_TRACKING_COOKIE_NAME = "hypn0_vid"
def site_context(request: HttpRequest) -> dict: def site_context(request: HttpRequest) -> dict:
+97
View File
@@ -0,0 +1,97 @@
from django import forms
class HalftoneGenerateForm(forms.Form):
"""
Форма валидации входных параметров для генерации гипнотического SVG.
"""
SHAPE_CHOICES = [
("circle", "Круг"),
("ring", "Кольцо"),
("triangle", "Треугольник"),
("square", "Квадрат"),
("diamond", "Ромб"),
("hexagon", "Шестиугольник"),
("star", "Звезда"),
("cross", "Крестик"),
("line", "Линия"),
("wave", "Волна"),
("heart", "Сердечко"),
]
image = forms.ImageField(
required=True,
error_messages={
"required": "Покорми Гипножабу изображением!",
"invalid_image": "Гипножаба не может переварить этот файл. Нужна картинка (PNG, JPG, WEBP).",
},
)
shape = forms.ChoiceField(
choices=SHAPE_CHOICES,
initial="circle",
required=False,
)
cols = forms.IntegerField(
min_value=10,
max_value=80,
initial=35,
required=False,
)
max_radius = forms.IntegerField(
min_value=3,
max_value=20,
initial=8,
required=False,
)
blink = forms.IntegerField(
min_value=0,
max_value=10,
initial=6,
required=False,
)
rotation = forms.IntegerField(
min_value=-15,
max_value=15,
initial=0,
required=False,
)
scale = forms.IntegerField(
min_value=800,
max_value=1040,
initial=980,
required=False,
)
angle = forms.IntegerField(
min_value=-44,
max_value=45,
initial=0,
required=False,
)
def clean_shape(self):
shape = self.cleaned_data.get("shape")
if not shape:
return "circle"
return shape
def clean_cols(self):
return self.cleaned_data.get("cols") or 80
def clean_max_radius(self):
return self.cleaned_data.get("max_radius") or 8
def clean_blink(self):
val = self.cleaned_data.get("blink")
return 6 if val is None else val
def clean_rotation(self):
val = self.cleaned_data.get("rotation")
return 0 if val is None else val
def clean_scale(self):
val = self.cleaned_data.get("scale")
return 980 if val is None else val
def clean_angle(self):
val = self.cleaned_data.get("angle")
return 0 if val is None else val
@@ -0,0 +1,105 @@
from django.core.management.base import BaseCommand
from django.conf import settings
from hashids import Hashids
from hypn0_site.models import TbHypn0Item
class Command(BaseCommand):
"""
Проверка и синхронизация s_hash_id с текущими настройками HASHIDS_SALT.
АРХИТЕКТУРНОЕ НАЗНАЧЕНИЕ:
=========================
1. Изоляция сред (Dev / Stage / Prod) и ротация секретов:
В разных окружениях или при переносе базы данных значение HASHIDS_SALT может
отличаться от исходного.
2. Детерминированность и обратимость:
Hashids — это однозначное обратимое кодирование: pk <-> s_hash_id.
Если соль изменилась, старые s_hash_id в базе перестают соответствовать
текущей конфигурации проекта.
3. Назначение команды:
- Сверяет каждый s_hash_id в базе с тем, что должен генерироваться при текущем settings.HASHIDS_SALT.
- Выявляет битые, пустые или сгенерированные со старой солью идентификаторы.
- Безопасно обновляет s_hash_id без изменения первичных ключей (pk) и других полей картины.
"""
help = "Проверка целостности и перегенерация s_hash_id картин под текущий HASHIDS_SALT"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Тестовый прогон: только проверить несоответствия без записи изменений в БД",
)
parser.add_argument(
"--batch-size",
type=int,
default=500,
help="Размер пакета для пакетного обновления (по умолчанию: 500)",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
batch_size = options["batch_size"]
salt = getattr(settings, "HASHIDS_SALT", "hypn0_default_salt")
min_length = getattr(settings, "HASHIDS_MIN_LENGTH", 6)
hasher = Hashids(salt=salt, min_length=min_length)
self.stdout.write(self.style.NOTICE(f"Старт проверки s_hash_id (соль: '{salt[:4]}***', min_length: {min_length})..."))
items = TbHypn0Item.objects.all().only("id", "s_hash_id")
total_items = items.count()
if total_items == 0:
self.stdout.write(self.style.WARNING("В базе данных нет картин для проверки."))
return
to_update = []
mismatched_count = 0
empty_count = 0
for item in items.iterator(chunk_size=batch_size):
expected_hash = hasher.encode(item.id)
if not item.s_hash_id:
empty_count += 1
item.s_hash_id = expected_hash
to_update.append(item)
elif item.s_hash_id != expected_hash:
mismatched_count += 1
self.stdout.write(
f"Несоответствие [ID={item.id}]: текущий='{item.s_hash_id}' -> ожидаемый='{expected_hash}'"
)
item.s_hash_id = expected_hash
to_update.append(item)
# Пакетное сохранение порциями
if not dry_run and len(to_update) >= batch_size:
TbHypn0Item.objects.bulk_update(to_update, fields=["s_hash_id"])
to_update.clear()
# Сохранение остатка
if not dry_run and to_update:
TbHypn0Item.objects.bulk_update(to_update, fields=["s_hash_id"])
total_issues = empty_count + mismatched_count
self.stdout.write("--------------------------------------------------")
self.stdout.write(f"Всего проверено записей: {total_items}")
self.stdout.write(f"Пустых s_hash_id: {empty_count}")
self.stdout.write(f"Несоответствующих s_hash_id: {mismatched_count}")
if total_issues == 0:
self.stdout.write(self.style.SUCCESS("Все s_hash_id в базе идеально соответствуют текущей соли!"))
else:
if dry_run:
self.stdout.write(
self.style.WARNING(f"[DRY RUN] Найдено {total_issues} несоответствий. Запустите без --dry-run для применения.")
)
else:
self.stdout.write(
self.style.SUCCESS(f"Успешно обновлено {total_issues} записей под текущую конфигурацию.")
)
@@ -0,0 +1,250 @@
import math
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.conf import settings
from django.db.models import Count, Q
from hypn0_site.models import TbHypn0Item, TbVote
class Command(BaseCommand):
"""
Пакетный пересчет рейтинга популярности (f_score) и выявление аномалий (накрутка / шейминг).
АРХИТЕКТУРНЫЕ ПРИНЦИПЫ СКОРИНГА HYPN0:
======================================
1. Изолированный атомарный скоринг бесполезен:
Оценивать одну картину в вакууме нельзя, так как система не видит контекста:
динамики платформы, скорости притока голосов и фонового шума.
2. Рейтинг f_score нужен для Smart Retention (умной очистки диска):
Он определяет, какие файлы удалять первыми при превышении дисковой квоты,
а какие оставлять как ценные экспонаты.
3. Разделение UI-счетчиков и весов скоринга:
Поля `i_likes_count` и `i_claims_count` в TbHypn0Item используются для быстрого рендера в UI.
Истинные веса (+1 лайк, +2 автор, -2 жалоба) + штампы-времени и sha256 от фингер-принтами браузеров хранятся в TbVote.
4. Учет "старения" голосов (Time-Decay) (алгоритм, возможно, будет пересмотрен):
Свежий лайк имеет полный вес, а старый голос плавно затухает по экспоненциальному закону:
Weight(t) = BaseWeight * 2^(-dt / HalfLife).
5. "Средняя температура по больнице" и Коридоры аномалий (алгоритм, возможно, будет пересмотрен:
Перед расчетом анализируется средняя скорость голосования по всей платформе за окно (например, 24ч).
- Спайк лайков (> N раз выше нормы): подозрение на накрутку (Level.SUSPICIOUS).
- Спайк клеймов (> N% от голосов за короткий промежуток): скоординированный шейминг (Level.SHAMED).
- Стабильный интерес: прогретая картина переходит в Level.LEVEL_1 (ожидает модерации).
6. Базовая формула гравитации с логарифмическим сглаживанием:
Score = (ln(1 + max(0, WeightedVotes)) + Bonus_level) / ((Age_in_hours + 2) ^ GAMMA)
"""
help = "Пакетный пересчет рейтинга популярности f_score и выявление аномалий в голосах"
def add_arguments(self, parser):
parser.add_argument(
"--gamma",
type=float,
default=getattr(settings, "GAMMA", 1.5),
help="Коэффициент гравитации затухания картины по времени (по умолчанию: 1.5)",
)
parser.add_argument(
"--half-life-days",
type=float,
default=14.0,
help="Период полураспада веса отдельного голоса в днях (по умолчанию: 14 дней)",
)
parser.add_argument(
"--spike-multiplier",
type=float,
default=4.0,
help="Во сколько раз скорость лайков должна превышать среднюю для детекции накрутки (по умолчанию: 4.0)",
)
parser.add_argument(
"--shame-claim-ratio",
type=float,
default=0.35,
help="Доля жалоб среди свежих голосов для детекции шейминга (по умолчанию: 0.35 или 35%)",
)
parser.add_argument(
"--warmup-votes-threshold",
type=int,
default=10,
help="Минимальное число взвешенных голосов для перехода CANDIDATE -> LEVEL_1 (по умолчанию: 10)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Тестовый прогон: выполнить все расчеты без записи изменений в БД",
)
def handle(self, *args, **options):
# =========================================================================================
# КОНФИГУРАЦИОННЫЕ КОНСТАНТЫ И "МАГИЧЕСКИЕ ЧИСЛА" СКОРИНГА
# =========================================================================================
# 1. Параметры времени и окна анализа
WINDOW_HOURS = 24.0 # Размер скользящего окна (в часах) для оценки текущей активности
ACTIVE_DAYS_HORIZON = 30.0 # Горизонт (в днях), картины моложе которого считаются "активными" для базы нормы
# 2. Пороги выборки и детекции аномалий
MIN_VOTES_FOR_ANOMALY = 5 # Минимальное число свежих оценок за окно, до которого детекторы аномалий спят
MIN_LIKES_FOR_SPIKE = 20 # Минимальное число лайков за окно, необходимое для признания относительного спайка
COLD_START_SPIKE_LIKES = 50 # Абсолютное число лайков за окно для детекции спайка в режиме "холодного старта"
# 3. Бонусы к рейтингу за модераторский статус (веса числителя формулы)
LEVEL_1_BONUS = 1.5 # Бонус картине со статусом LEVEL_1 (прогрета / замечена модератором)
LEVEL_2_BONUS = 4.0 # Бонус картине со статусом LEVEL_2 (одобрено Мозговым Слизнем / шедевр)
# 4. Коэффициент сглаживания инерции (EMA — Exponential Moving Average)
EMA_ALPHA = 0.7 # Доля нового значения в итоговом рейтинге (0.7 = 70% новый расчет + 30% старый f_score)
# 5. Смещение возраста в формуле гравитации (Hacker News Gravity Offset)
GRAVITY_AGE_OFFSET_HOURS = 2.0 # Добавка к возрасту картины в часах, исключающая деление на 0 в первые минуты жизни
# Параметры из аргументов CLI (переопределяемые)
gamma = options["gamma"]
half_life_days = options["half_life_days"]
spike_multiplier = options["spike_multiplier"]
shame_claim_ratio = options["shame_claim_ratio"]
warmup_threshold = options["warmup_votes_threshold"]
dry_run = options["dry_run"]
now = timezone.now()
self.stdout.write(self.style.NOTICE(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] Старт скоринга Hypn0..."))
# -----------------------------------------------------------------------------------------
# ШАГ 1: Определение "средней температуры по больнице" за последние WINDOW_HOURS
# -----------------------------------------------------------------------------------------
# ЗАЩИТА "ХОЛОДНОГО СТАРТА" (Cold Start / Нулевая база):
# Если проект только запущен или база пуста, средние значения равны нулю,
# а статистическая выборка отсутствует. В этом режиме детекторы спайков
# используют безопасные абсолютные пороги, чтобы не давать ложных тревог.
# -----------------------------------------------------------------------------------------
recent_window = now - timedelta(hours=WINDOW_HOURS)
recent_votes_qs = TbVote.objects.filter(d_created_at__gte=recent_window)
total_recent_votes = recent_votes_qs.count()
active_items_count = TbHypn0Item.objects.filter(
d_created_at__gte=now - timedelta(days=ACTIVE_DAYS_HORIZON)
).count()
if active_items_count == 0 or total_recent_votes == 0:
avg_votes_per_item_24h = 0.0
is_cold_start = True
self.stdout.write(
self.style.NOTICE("Режим [Холодный старт]: платформа только запустилась, голосов мало/нет.")
)
else:
avg_votes_per_item_24h = total_recent_votes / active_items_count
is_cold_start = False
self.stdout.write(
f"Базовые метрики за {int(WINDOW_HOURS)}ч: голосов всего={total_recent_votes}, "
f"активных картин={active_items_count}, среднее={avg_votes_per_item_24h:.2f} голосов/картину"
)
# -----------------------------------------------------------------------------------------
# ШАГ 2: Выборка картин для пересчета
# Бессмертные картины (Level.IMMORTAL) не требуют скоринга для очистки, но могут участвовать
# -----------------------------------------------------------------------------------------
items = TbHypn0Item.objects.exclude(i_level=TbHypn0Item.Level.IMMORTAL).prefetch_related("votes")
total_items = items.count()
if total_items == 0:
self.stdout.write(self.style.WARNING("В базе данных пока нет картин для пересчета."))
return
updated_count = 0
decay_lambda = math.log(2) / (half_life_days * 24.0) # константа затухания в час
for item in items:
# 1. Считаем взвешенные голоса с учетом возраста каждого голоса
item_votes = list(item.votes.all())
weighted_likes = 0.0
recent_likes_24h = 0
recent_claims_24h = 0
for v in item_votes:
vote_age_hours = max(0.0, (now - v.d_created_at).total_seconds() / 3600.0)
# Экспоненциальное затухание веса: e^(-lambda * dt)
decay_factor = math.exp(-decay_lambda * vote_age_hours)
weighted_likes += v.i_direction * decay_factor
if v.d_created_at >= recent_window:
if v.i_direction in (TbVote.Direction.LIKE, TbVote.Direction.AUTHOR):
recent_likes_24h += 1
elif v.i_direction == TbVote.Direction.CLAIM:
recent_claims_24h += 1
# 2. Детекция аномалий (шейминг vs спайк накрутки)
new_level = item.i_level
recent_total_24h = recent_likes_24h + recent_claims_24h
# Проверка аномалий активируется только при наличии минимальной выборки (>= MIN_VOTES_FOR_ANOMALY)
if recent_total_24h >= MIN_VOTES_FOR_ANOMALY:
# Проверка на шейминг (резкий наплыв жалоб)
claim_ratio = recent_claims_24h / recent_total_24h
if claim_ratio >= shame_claim_ratio:
new_level = TbHypn0Item.Level.SHAMED
self.stdout.write(
self.style.WARNING(f"Аномалия [SHAMED]: Картина {item.s_hash_id} доля жалоб={claim_ratio:.1%}")
)
# Проверка на спайк лайков (накрутка):
# На холодном старте (когда avg_votes_per_item_24h близко к 0) опираемся только на высокий абсолютный порог COLD_START_SPIKE_LIKES,
# чтобы 3-4 первых лайка от живых пользователей не помечались как подозрительные.
elif not is_cold_start and avg_votes_per_item_24h > 0:
if recent_likes_24h > (avg_votes_per_item_24h * spike_multiplier) and recent_likes_24h >= MIN_LIKES_FOR_SPIKE:
new_level = TbHypn0Item.Level.SUSPICIOUS
self.stdout.write(
self.style.WARNING(f"Аномалия [SUSPICIOUS]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}")
)
elif is_cold_start and recent_likes_24h >= COLD_START_SPIKE_LIKES:
new_level = TbHypn0Item.Level.SUSPICIOUS
self.stdout.write(
self.style.WARNING(f"Аномалия [SUSPICIOUS (Cold Start)]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}")
)
# Проверка перехода CANDIDATE -> LEVEL_1 (Прогрета, набрала массу)
if item.i_level == TbHypn0Item.Level.CANDIDATE and weighted_likes >= warmup_threshold and new_level == item.i_level:
new_level = TbHypn0Item.Level.LEVEL_1
self.stdout.write(
self.style.SUCCESS(f"Прогрев [CANDIDATE -> LEVEL_1]: Картина {item.s_hash_id} набрала вес={weighted_likes:.1f}")
)
# 3. Расчет нового f_score (Гравитационная модель + логарифм)
item_age_hours = max(0.0, (now - item.d_created_at).total_seconds() / 3600.0)
# Бонус от модераторского уровня
level_bonus = 0.0
if item.i_level == TbHypn0Item.Level.LEVEL_1:
level_bonus = LEVEL_1_BONUS
elif item.i_level == TbHypn0Item.Level.LEVEL_2:
level_bonus = LEVEL_2_BONUS
if weighted_likes <= 0:
raw_score = weighted_likes # Отрицательный или нулевой скор
else:
raw_score = (math.log(1.0 + weighted_likes) + level_bonus) / ((item_age_hours + GRAVITY_AGE_OFFSET_HOURS) ** gamma)
# 4. Инерционное сглаживание со старым значением (EMA) для исключения скачков
if item.f_score != 0.0:
final_score = EMA_ALPHA * raw_score + (1.0 - EMA_ALPHA) * item.f_score
else:
final_score = raw_score
# 5. Сохранение результатов
if not dry_run:
item.f_score = round(final_score, 6)
if new_level != item.i_level:
item.i_level = new_level
item.save(update_fields=["f_score", "i_level", "d_updated_at"])
else:
item.save(update_fields=["f_score", "d_updated_at"])
updated_count += 1
status_msg = "ПРОБНЫЙ ПРОГОН (DRY RUN)" if dry_run else "УСПЕШНО ОБНОВЛЕНО"
self.stdout.write(
self.style.SUCCESS(f"[{status_msg}] Обработано картин: {updated_count}/{total_items}")
)
@@ -0,0 +1,79 @@
# Generated by Django 6.1 on 2026-08-22 11:14
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='TbBlogPost',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False, verbose_name='ID')),
('s_title', models.CharField(help_text='HTML-заголовок (с HTML-тегами, типографированный etpgrf)', max_length=255, verbose_name='Заголовок')),
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-слаг')),
('s_teaser', models.TextField(blank=True, help_text='HTML-контент тизера, очищенный и типографированный etpgrf', max_length=1024, verbose_name='Краткое описание / Лид')),
('s_content', models.TextField(help_text='HTML-контент статьи, очищенный и типографированный etpgrf', verbose_name='Основной HTML-контент')),
('f_cover_img', models.ImageField(blank=True, null=True, upload_to='blog/covers/%Y/', verbose_name='Обложка')),
('is_published', models.BooleanField(default=False, verbose_name='Опубликовано')),
('d_published_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Дата публикации')),
('d_created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('d_updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Статья блога / Страница',
'verbose_name_plural': 'Статьи блога / Страницы',
'ordering': ['-d_published_at', '-d_created_at'],
},
),
migrations.CreateModel(
name='TbHypn0Item',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID')),
('s_hash_id', models.CharField(max_length=16, unique=True, verbose_name='ID-Хэш')),
('s_title', models.CharField(help_text='Заголовок для генерации. Включена HTML-типографика. 255 символов максимум.', max_length=255, verbose_name='Название генерации')),
('file_svg', models.FileField(help_text='Результирующий SVG-файл генерации', upload_to='svg/%Y/%m/', verbose_name='SVG-файл')),
('i_file_size', models.PositiveIntegerField(default=0, help_text='Размер файла в байтах', verbose_name='Размер файла')),
('j_metadata', models.JSONField(blank=True, default=dict, help_text='Метаданные и параметры для возможного клонирования настроек', verbose_name='Параметры генерации')),
('i_likes_count', models.PositiveIntegerField(db_index=True, default=0, verbose_name='Число лайков')),
('i_views_count', models.PositiveIntegerField(default=0, verbose_name='Число просмотров')),
('i_claims_count', models.PositiveIntegerField(default=0, verbose_name='Число жалоб')),
('f_score', models.FloatField(db_index=True, default=0.0, help_text='Рейтинг для Smart Retention и определения, что удалять при чистке по лимитам, а что оставлять', verbose_name='Рейтинг популярности')),
('i_level', models.IntegerField(choices=[(0, 'Candidate: Шум сознания'), (10, 'Voted: Плеск бессознательного'), (30, 'Moderated: Одобрено Мозговым Слизнем'), (1000, 'Locked: Глубокий транс')], default=0, help_text='Уровень', verbose_name='В какой группе находится SVG-файл генерации')),
('is_public', models.BooleanField(default=True, help_text='Доступность по прямой ссылке', verbose_name='Публичный доступ')),
('s_promo_url', models.URLField(blank=True, default='', verbose_name='Промо-ссылка')),
('s_promo_title', models.CharField(blank=True, default='', max_length=100, verbose_name='Бейдж/автор промо')),
('i_promo_clicks', models.PositiveIntegerField(default=0, verbose_name='Клики по промо')),
('d_created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')),
('d_updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Гипно-картина',
'verbose_name_plural': 'Гипно-картины',
'ordering': ['-d_created_at'],
},
),
migrations.CreateModel(
name='TbVote',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='ID')),
('i_direction', models.IntegerField(choices=[(1, 'Like (+1)'), (-2, 'Clime (-2)'), (2, 'Author Like (+2)')], default=1, help_text='Направление голоса', verbose_name='Направление')),
('s_fingerprint', models.CharField(db_index=True, help_text='SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств', max_length=64, verbose_name='Хэш устройства/отпечаток')),
('d_created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата голосования')),
('k_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='hypn0_site.tbhypn0item', verbose_name='Картина')),
],
options={
'verbose_name': 'Голос / Лайк',
'verbose_name_plural': 'Голоса / Лайки',
'ordering': ['-d_created_at'],
'constraints': [models.UniqueConstraint(fields=('k_item', 's_fingerprint'), name='unique_item_fingerprint_vote')],
},
),
]
@@ -0,0 +1,62 @@
# Generated by Django 6.1 on 2026-08-24 11:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hypn0_site', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='tbvote',
options={'ordering': ['-d_created_at'], 'verbose_name': 'Голос/Лайк', 'verbose_name_plural': 'Голоса/Лайки'},
),
migrations.AlterField(
model_name='tbblogpost',
name='id',
field=models.SmallAutoField(help_text='Первичный ключ (SmallAutoField, до 32 767 записей)', primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_claims_count',
field=models.PositiveIntegerField(default=0, help_text='Число жалоб на гипнотическую SVG-картину. Поле используется для отображения в интерфейсе. Для расчета рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.', verbose_name='Число жалоб'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_level',
field=models.IntegerField(choices=[(0, 'Candidate: Шум сознания'), (10, 'Voted: Плеск бессознательного'), (30, 'Moderated: Одобрено Мозговым Слизнем'), (1000, 'Locked: Глубокий транс'), (-20, 'Шейминг (аномальный наплыв жалоб)'), (-10, 'Подозрение на накрутку (спайк лайков)')], default=0, help_text='Уровень', verbose_name='В какой группе находится SVG-файл генерации'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_likes_count',
field=models.PositiveIntegerField(db_index=True, default=1, help_text='Число лайков гипнотической SVG-картины. Поле используется для отображения в интерфейсе. Для расчета рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.', verbose_name='Число лайков'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_views_count',
field=models.PositiveIntegerField(default=1, verbose_name='Число просмотров'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='id',
field=models.AutoField(help_text='Первичный ключ (~2.14 млрд записей, 4 байта)', primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='s_hash_id',
field=models.CharField(editable=False, max_length=16, unique=True, verbose_name='ID-Хэш'),
),
migrations.AlterField(
model_name='tbvote',
name='i_direction',
field=models.IntegerField(choices=[(1, 'Like (+1)'), (-2, 'Claim (-2)'), (2, 'Author Like (+2)')], default=1, help_text='Направление голоса', verbose_name='Направление'),
),
migrations.AlterField(
model_name='tbvote',
name='id',
field=models.BigAutoField(help_text='Первичный ключ (BigAutoField, 8 байт)', primary_key=True, serialize=False, verbose_name='ID'),
),
]
+407 -2
View File
@@ -1,3 +1,408 @@
from django.db import models import hashlib
from django.db import models, transaction, IntegrityError
from django.db.models import F
from hashids import Hashids
from hypn0.settings import *
from django.utils import timezone
# Create your models here. class TbHypn0Item(models.Model):
"""
Единая модель для генераций, публичных шеров и витрины галереи.
Поля:
• id: идентификатор записи (AutoField, 4 байта, до ~2.14 млрд)
• s_hash_id: хэш-сумма шеринга для SVG-генерации (Hashids)
• s_title: Заголовок (до 255 символов, разрешен HTML)
• file_svg: SVG-файл генерации
• i_file_size: размер файла в байтах
• j_metadata: метаданные файла JSON (параметры алгоритма генерации)
• i_likes_count: количество лайков
• i_views_count: количество просмотров
• i_claims_count: количество жалоб/претензий (клеймов)
• f_score: оценка для стратегии хранения и умной очистки (Smart Retention)
• i_level: уровень хранения (свежее, модерированное, защищенное от удаления)
• is_public: доступность по внешней ссылке
• s_promo_url: промо-ссылка спонсора/автора
• s_promo_title: заголовок промо-ссылки или бейджа
• i_promo_clicks: количество переходов по промо-ссылке
• d_created_at: дата создания записи
• d_updated_at: дата последнего обновления записи
Методы:
• save(): формирование s_hash_id
• rescore(): пересчёт рейтинга f_score
"""
class Level(models.IntegerChoices):
CANDIDATE = 0, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
LEVEL_1 = 10, 'Voted: Плеск бессознательного' # Прогрето, есть "лайки", не проверено модератором, можно удалять при низком score
LEVEL_2 = 30, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
IMMORTAL = 1000, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком score
# Отрицательные / проблемные уровни (аномалии)
SHAMED = -20, 'Шейминг (аномальный наплыв жалоб)'
SUSPICIOUS = -10, 'Подозрение на накрутку (спайк лайков)'
id = models.AutoField(
primary_key=True,
verbose_name="ID",
help_text="Первичный ключ (~2.14 млрд записей, 4 байта)",
)
s_hash_id = models.CharField(
max_length=16,
unique=True,
editable=False,
verbose_name="ID-Хэш",
)
s_title = models.CharField(
max_length=255,
verbose_name="Название генерации",
help_text="Заголовок для генерации. Включена HTML-типографика. 255 символов максимум.",
)
file_svg = models.FileField(
upload_to="svg/%Y/%m/",
verbose_name="SVG-файл",
help_text="Результирующий SVG-файл генерации",
)
i_file_size = models.PositiveIntegerField(
default=0,
verbose_name="Размер файла",
help_text="Размер файла в байтах",
)
# НА ВСЯКИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек
j_metadata = models.JSONField(
default=dict,
blank=True,
verbose_name="Параметры генерации",
help_text="Метаданные и параметры для возможного клонирования настроек",
)
# Популярность
i_likes_count = models.PositiveIntegerField(
default=1,
db_index=True,
verbose_name="Число лайков",
help_text="Число лайков гипнотической SVG-картины. Поле используется для отображения в интерфейсе. Для расчета"
" рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы"
" <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.",
)
i_views_count = models.PositiveIntegerField(
default=1,
verbose_name="Число просмотров",
)
i_claims_count = models.PositiveIntegerField(
default=0,
verbose_name="Число жалоб",
help_text="Число жалоб на гипнотическую SVG-картину. Поле используется для отображения в интерфейсе. Для расчета"
" рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы"
" <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.",
)
f_score = models.FloatField(
default=0.0,
db_index=True,
verbose_name="Рейтинг популярности",
help_text="Рейтинг для Smart Retention и определения, что удалять при чистке по лимитам, а что оставлять",
)
# Модерация
i_level = models.IntegerField(
choices=Level.choices,
default=Level.CANDIDATE,
help_text="Уровень",
verbose_name="В какой группе находится SVG-файл генерации",
)
is_public = models.BooleanField(
default=True,
help_text="Доступность по прямой ссылке",
verbose_name="Публичный доступ",
)
# Промо-блок (на перспективу)
s_promo_url = models.URLField(
blank=True,
default="",
verbose_name="Промо-ссылка",
)
s_promo_title = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="Бейдж/автор промо",
)
i_promo_clicks = models.PositiveIntegerField(
default=0,
verbose_name="Клики по промо",
)
d_created_at = models.DateTimeField(
auto_now_add=True,
db_index=True,
verbose_name="Дата создания",
)
d_updated_at = models.DateTimeField(
auto_now=True,
db_index=True,
verbose_name="Дата обновления",
)
class Meta:
verbose_name = "Гипно-картина"
verbose_name_plural = "Гипно-картины"
ordering = ["-d_created_at"]
def __str__(self) -> str:
return f"{self.s_title} ({self.s_hash_id})"
def increment_views(self):
"""Безопасный инкремент просмотров"""
TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1)
def vote(self, visitor_uuid_or_fp: str, direction: int | None = None) -> bool:
"""
Универсальный метод учета голоса (лайк, жалоба или авторство).
Принимает:
- visitor_uuid_or_fp: UUIDv4 из куки hypn0_vid или готовый 64-символьный SHA256-хэш отпечатка
- direction: направление голоса (Direction.LIKE, Direction.CLAIM, Direction.AUTHOR)
Возвращает:
- True: голос успешно зафиксирован и счетчик увеличен
- False: голос уже существовал (защита от повторного голосования) или передан пустой токен
"""
if not visitor_uuid_or_fp:
return False
if direction is None:
direction = TbVote.Direction.LIKE
# Если передан сырой UUID устройства, вычисляем SHA256 отпечаток
if len(visitor_uuid_or_fp) == 64:
fingerprint = visitor_uuid_or_fp
else:
fingerprint = hashlib.sha256(f"{visitor_uuid_or_fp}:{SECRET_KEY}".encode()).hexdigest()
try:
# В SQLite при возникновении IntegrityError текущая транзакция может перейти в состояние ошибки. Чтобы это
# не затрагивало последующие операции с базой, try-записи изолирована в `with transaction.atomic()`
with transaction.atomic():
TbVote.objects.create(
k_item=self,
i_direction=direction,
s_fingerprint=fingerprint,
)
except IntegrityError:
# Нарушение UniqueConstraint (k_item, s_fingerprint) — голос с этого устройства уже учтен
return False
# Атомарный инкремент нужного счетчика в зависимости от направления
# Используем `objects.filter()` вместо `objects.get()`, так как метод .update() вызывается только у QuerySet.
if direction in (TbVote.Direction.LIKE, TbVote.Direction.AUTHOR):
TbHypn0Item.objects.filter(id=self.id).update(i_likes_count=F('i_likes_count') + 1)
elif direction == TbVote.Direction.CLAIM:
TbHypn0Item.objects.filter(id=self.id).update(i_claims_count=F('i_claims_count') + 1)
# Рейтинг популярности f_score намеренно НЕ пересчитывается на лету при каждом лайке:
# 1. f_score зависит от времени (возраста картины), поэтому непрерывно меняется сам по себе.
# 2. Значение f_score необходимо только периодическому процессу умной очистки диска (Smart Retention).
# 3. Пересчет вынесен в пакетную cron-задачу перед ротацией хранилища, что исключает лишние I/O-операции в SQLite.
return True
def increment_likes(self, visitor_uuid_or_fp: str) -> bool:
"""Безопасный инкремент лайков с фиксацией в TbVote"""
return self.vote(visitor_uuid_or_fp, direction=TbVote.Direction.LIKE)
def increment_claims(self, visitor_uuid_or_fp: str) -> bool:
"""Безопасный инкремент жалоб с фиксацией в TbVote"""
return self.vote(visitor_uuid_or_fp, direction=TbVote.Direction.CLAIM)
def increment_promo_clicks(self):
"""Безопасный инкремент кликов по промо"""
TbHypn0Item.objects.filter(id=self.id).update(i_promo_clicks=F('i_promo_clicks') + 1)
def save(self, *args, visitor_uuid_or_fp: str | None = None, **kwargs):
"""
Переопределенный метод save для TbHypn0Item.
ПРАВИЛА И СЦЕНАРИИ СОХРАНЕНИЯ:
------------------------------
1. Создание новой картины (is_new=True):
- Создание возможно ТОЛЬКО с фронтенда при наличии согласия на отслеживание (visitor_uuid_or_fp).
- Если пользователь не "подчинился Гипножабе" (visitor_uuid_or_fp отсутствует), создание БЛОКИРУЕТСЯ
выбрасыванием PermissionError (защита от сохранения анонимных работ без автора).
- В рамках одной атомарной транзакции:
а) Сохраняется запись картины и генерируется self.pk.
б) Генерируется криптографический s_hash_id.
в) Создается обязательная авторская запись TbVote(Direction.AUTHOR).
г) Стартовый счетчик i_likes_count по умолчанию равен 1.
2. Редактирование существующей картины (is_new=False, например из Django Admin):
- Картина уже существует в БД (self.pk есть).
- Разрешено обычное обновление любых полей (название, уровень модерации, рейтинг и т.д.).
- Никакие авторские записи повторно не создаются.
"""
is_new = self.pk is None
# 0. Блокировка создания новой картины без подчинения Гипножабе
if is_new and not visitor_uuid_or_fp:
raise PermissionError("Создание картины запрещено: пользователь не подчинился Гипножабе (отсутствует visitor UUID)")
# 1. Вычисляем хэш автора для новой картины
author_fp = None
if is_new and visitor_uuid_or_fp:
if len(visitor_uuid_or_fp) == 64:
author_fp = visitor_uuid_or_fp
else:
author_fp = hashlib.sha256(f"{visitor_uuid_or_fp}:{SECRET_KEY}".encode()).hexdigest()
with transaction.atomic():
# 2. Сохраняем объект в базу (для новых записей СУБД присваивает self.pk)
super().save(*args, **kwargs)
# 3. Формируем s_hash_id, если его еще нет
if not self.s_hash_id:
self.s_hash_id = Hashids(salt=HASHIDS_SALT, min_length=HASHIDS_MIN_LENGTH).encode(self.pk)
super().save(update_fields=['s_hash_id'])
# 4. Регистрируем автора в TbVote (гарантированно после получения self.pk)
if is_new and author_fp:
TbVote.objects.create(
k_item=self,
i_direction=TbVote.Direction.AUTHOR,
s_fingerprint=author_fp,
)
class TbVote(models.Model):
"""
Анонимный учет голосов (лайков, жалоб, авторства) без сохранения ПДн.
Поля:
• id: первичный ключ записи (BigAutoField, 8 байт, до ~9×10¹⁸)
• k_item: ключ объекта (foreign key), за который голосуют
• i_direction: направление голоса (лайк, жалоба или голос автора)
• s_fingerprint: хэш-отпечаток клиент/браузер/секрет для защиты от накрутки
• d_created_at: дата и время фиксации голоса
"""
class Direction(models.IntegerChoices):
LIKE = 1, 'Like (+1)'
CLAIM = -2, 'Claim (-2)'
AUTHOR = 2, 'Author Like (+2)'
id = models.BigAutoField(
primary_key=True,
verbose_name="ID",
help_text="Первичный ключ (BigAutoField, 8 байт)",
)
k_item = models.ForeignKey(
TbHypn0Item,
on_delete=models.CASCADE,
related_name="votes",
verbose_name="Картина",
)
i_direction = models.IntegerField(
choices=Direction.choices,
default=Direction.LIKE,
verbose_name="Направление",
help_text="Направление голоса",
)
s_fingerprint = models.CharField(
max_length=64,
db_index=True,
verbose_name="Хэш устройства/отпечаток",
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств",
)
d_created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата голосования",
)
class Meta:
verbose_name = "Голос/Лайк"
verbose_name_plural = "Голоса/Лайки"
constraints = [
models.UniqueConstraint(
fields=["k_item", "s_fingerprint"],
name="unique_item_fingerprint_vote",
)
]
ordering = ["-d_created_at"]
def __str__(self) -> str:
return f"Голос за {self.k_item.s_hash_id} [{self.s_fingerprint[:8]}...]"
class TbBlogPost(models.Model):
"""
Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
Поля:
• id: первичный ключ статьи (SmallAutoField, 2 байта, до 32 767 записей)
• s_title: заголовок статьи (HTML)
• slug: слаг (URI-адрес) статьи
• s_teaser: тизер статьи (HTML)
• s_content: основной текст статьи (HTML)
• f_cover_img: картинка обложки статьи
• is_published: флаг публикации
• d_published_at: дата и время публикации
• d_created_at: дата создания записи
• d_updated_at: дата обновления записи
"""
id = models.SmallAutoField(
primary_key=True,
verbose_name="ID",
help_text="Первичный ключ (SmallAutoField, до 32 767 записей)",
)
s_title = models.CharField(
max_length=255,
help_text="HTML-заголовок (с HTML-тегами, типографированный etpgrf)",
verbose_name="Заголовок",
)
slug = models.SlugField(
max_length=200,
blank=True,
default="",
unique=True,
verbose_name="URL-слаг",
)
s_teaser = models.TextField(
max_length=1024,
blank=True,
help_text="HTML-контент тизера, очищенный и типографированный etpgrf",
verbose_name="Краткое описание / Лид",
)
s_content = models.TextField(
help_text="HTML-контент статьи, очищенный и типографированный etpgrf",
verbose_name="Основной HTML-контент",
)
f_cover_img = models.ImageField(
upload_to="blog/covers/%Y/",
blank=True,
null=True,
verbose_name="Обложка",
)
is_published = models.BooleanField(
default=False,
verbose_name="Опубликовано",
)
d_published_at = models.DateTimeField(
default=timezone.now,
db_index=True,
verbose_name="Дата публикации",
)
d_created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания",
)
d_updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления",
)
class Meta:
verbose_name = "Статья блога / Страница"
verbose_name_plural = "Статьи блога / Страницы"
ordering = ["-d_published_at", "-d_created_at"]
def __str__(self) -> str:
return self.s_title
+1
View File
@@ -0,0 +1 @@
# Services package for hypn0_site
+283
View File
@@ -0,0 +1,283 @@
import io
import math
import random
from collections import defaultdict
from typing import BinaryIO, Union
from PIL import Image
def encode_to_base36(num: int) -> str:
"""Кодирует неотрицательное число в компактный строковый идентификатор."""
if num < 0:
raise ValueError("Число должно быть неотрицательным")
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
if num < len(charset):
return charset[num]
result = []
while num > 0:
result.append(charset[num % len(charset)])
num //= len(charset)
return "".join(reversed(result))
def generate_shape_def(shape: str, radius: int, shape_id: str) -> str:
"""Генерирует SVG-элемент для тега <defs> в зависимости от типа фигуры."""
r = radius
match shape:
case "ring":
stroke_width = max(1.0, r * 0.35)
inner_r = max(0.5, r - stroke_width / 2.0)
return f'<circle id="{shape_id}" r="{inner_r:.1f}" fill="none" stroke="currentColor" stroke-width="{stroke_width:.1f}"/>'
case "square":
size = r * 2
rx = max(0.5, r * 0.1)
return f'<rect id="{shape_id}" class="shape" x="{-r}" y="{-r}" width="{size}" height="{size}" rx="{rx:.1f}"/>'
case "diamond":
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {-r},0 0,{r} {r},0"/>'
case "triangle":
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {-r},{r} {r},{r}"/>'
case "hexagon":
w = round(r * 0.866)
h_half = round(r * 0.5)
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {w},{-h_half} {w},{h_half} 0,{r} {-w},{h_half} {-w},{-h_half}"/>'
case "star":
points = []
inner_r = r * 0.45
for i in range(10):
angle = i * math.pi / 5 - math.pi / 2
curr_r = r if i % 2 == 0 else inner_r
px = round(curr_r * math.cos(angle), 1)
py = round(curr_r * math.sin(angle), 1)
points.append(f"{px},{py}")
pts_str = " ".join(points)
return f'<polygon id="{shape_id}" class="shape" points="{pts_str}"/>'
case "cross":
arm = max(1, round(r * 0.35))
return f'<path id="{shape_id}" class="shape" d="M{-arm},{-r} H{arm} V{-arm} H{r} V{arm} H{arm} V{r} H{-arm} V{arm} H{-r} V{-arm} H{-arm} Z"/>'
case "line":
th = max(1, round(r * 0.25))
return f'<rect id="{shape_id}" class="shape" x="{-r}" y="{-th}" width="{2*r}" height="{2*th}" rx="1"/>'
case "wave":
th = max(1.0, r * 0.3)
return f'<path id="{shape_id}" class="shape" d="M{-r},0 Q{-r/2:.1f},{-r} 0,0 T{r},0" fill="none" stroke="currentColor" stroke-width="{th:.1f}"/>'
case "heart":
scale_f = r / 12.0
return (
f'<path id="{shape_id}" class="shape" transform="scale({scale_f:.3f}) translate(-12, -12)" '
f'd="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>'
)
case "circle" | _:
return f'<circle id="{shape_id}" class="shape" r="{r}"/>'
def generate_halftone_svg(
image: Union[Image.Image, BinaryIO, bytes, str],
*,
cols: int = 35,
max_radius: int = 8,
shape: str = "circle",
color: str = "#a855ff",
opacity: float = 0.9,
blink: int = 6,
rotation: int = 0,
scale: int = 980,
angle: int = 0,
animation_variants: int = 12,
seed: int = 42,
) -> str:
"""
Генерирует чистый оптимизированный SVG-халфтон из растрового изображения.
Параметры:
- image: PIL Image, файловый объект (BytesIO), байты или путь к файлу
- cols: число точек по наибольшей стороне сетки (10-200)
- max_radius: максимальный радиус/размер точки (3-20)
- shape: тип фигуры ('circle', 'ring', 'square', 'diamond', 'triangle', 'hexagon', 'star', 'cross', 'line', 'wave', 'heart')
- color: основной HEX цвет (например '#a855ff')
- opacity: коэффициент непрозрачности (0.0 - 1.0)
- blink: интенсивность мерцания (0 - выключено/статичный, 1-10 - скорость)
- rotation: угол покачивания в градусах (-15 .. +15)
- scale: масштаб пульсации (800 .. 1040, где 980 = 0.98)
- angle: наклон растровой сетки (-44 .. +45)
- animation_variants: число вариантов CSS-задержек
- seed: сид для детерминированного распределения анимаций
"""
rng = random.Random(seed)
# 1. Загрузка и подготовка изображения в градациях серого
if isinstance(image, (bytes, bytearray)):
pil_img = Image.open(io.BytesIO(image))
elif hasattr(image, "read"):
if hasattr(image, "seek"):
image.seek(0)
pil_img = Image.open(image)
elif isinstance(image, str):
pil_img = Image.open(image)
elif isinstance(image, Image.Image):
pil_img = image
else:
raise ValueError("Неподдерживаемый тип входного изображения")
# Конвертируем в Grayscale
img = pil_img.convert("L")
# Обработка наклона сетки (angle): поворачиваем изображение при необходимости
if angle != 0:
# Поворачиваем с сохранением пропорций и белым фоном (255 = прозрачно/пусто)
img = img.rotate(-angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=255)
# Вычисляем размеры сетки: значение `cols` задает число точек по наибольшей стороне (ширине или высоте)
if img.width >= img.height:
grid_width = max(1, cols)
grid_height = max(1, round(cols * (img.height / img.width)))
else:
grid_height = max(1, cols)
grid_width = max(1, round(cols * (img.width / img.height)))
img_resized = img.resize((grid_width, grid_height), Image.Resampling.LANCZOS)
step = max_radius * 2 + 2
width = grid_width * step
height = grid_height * step
# 2. Формирование классов анимации
animation_classes = []
is_animated = blink > 0
# Длительность цикла анимации: при blink=10 -> 0.6s, при blink=1 -> 2.4s
duration = max(0.4, 2.5 - (blink * 0.19)) if is_animated else 1.0
for i in range(animation_variants):
delay = (i / animation_variants) * duration * 1.5
encoded_i = encode_to_base36(i)
delay_str = f"{int(delay)}" if delay == int(delay) else f"{delay:.2f}".rstrip("0").rstrip(".")
animation_classes.append(f".a{encoded_i}{{--d:{delay_str}s}}")
# 3. Обход пикселей и группировка
elements_by_class = defaultdict(list)
unique_radii = set()
for y in range(grid_height):
for x in range(grid_width):
brightness = img_resized.getpixel((x, y))
factor = (255 - brightness) / 255.0
# Отсекаем слишком светлые участки (шум фона)
if factor < 0.12:
continue
radius = int(factor * max_radius)
if radius == 0:
continue
cx = x * step + step // 2
cy = y * step + step // 2
anim_class = rng.randint(0, animation_variants - 1)
encoded_class = encode_to_base36(anim_class)
elements_by_class[encoded_class].append((radius, cx, cy))
unique_radii.add(radius)
# 4. Формирование <defs>
defs_list = ["<defs>"]
for radius in sorted(unique_radii):
shape_id = f"s{encode_to_base36(radius)}"
defs_list.append(generate_shape_def(shape, radius, shape_id))
defs_list.append("</defs>")
defs_html = "".join(defs_list)
# 5. Формирование групп <g class="a...">
groups = []
for anim_class in sorted(elements_by_class.keys()):
group_elements = elements_by_class[anim_class]
uses = "".join(
f'<use href="#s{encode_to_base36(radius)}" x="{cx}" y="{cy}"/>'
for radius, cx, cy in group_elements
)
if uses:
groups.append(f'<g class="a{anim_class}">{uses}</g>')
animation_css = "".join(animation_classes)
groups_html = "".join(groups)
# 6. Стилизация и Keyframes
scale_val = max(0.5, min(1.5, scale / 1000.0))
scale_str = f"{scale_val:.3f}".rstrip("0").rstrip(".")
rot_str = f"{rotation}deg"
# Корректный цвет с прозрачностью
clean_color = color.strip() if color else "#a855ff"
if not clean_color.startswith("#"):
clean_color = f"#{clean_color}"
# Добавляем альфа-канал в hex при необходимости или используем CSS opacity
fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.2f};"
if is_animated:
anim_rule = f"animation:noise {duration:.2f}s ease-in-out infinite alternate;animation-delay:var(--d);"
keyframes_rule = (
f"@keyframes noise{{"
f"0%{{opacity:{max(0.2, opacity * 0.7):.2f};transform:scale(1) rotate(0deg)}}"
f"50%{{opacity:{opacity:.2f}}}"
f"100%{{opacity:{max(0.1, opacity * 0.5):.2f};transform:scale({scale_str}) rotate({rot_str})}}"
f"}}"
)
else:
anim_rule = "animation:none;"
keyframes_rule = ""
svg_css = (
f"<style>"
f"svg{{background:transparent}}"
f".shape,circle,rect,polygon,path{{{fill_style}transform-origin:center;{anim_rule}transition:all .5s ease-out}}"
f"{keyframes_rule}"
f".frozen .shape,.frozen circle,.frozen rect,.frozen polygon,.frozen path{{animation:none!important;opacity:{opacity:.2f}!important;transform:none!important}}"
f"{animation_css}"
f"</style>"
)
svg_template = (
f'<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->\n'
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="100%" height="100%">'
f'{svg_css}'
f'{defs_html}'
f'<g id="hypn0-canvas">{groups_html}</g>'
f'</svg>'
)
return svg_template
def prepare_gallery_svg(svg_content: str) -> str:
"""
Модифицирует SVG для долговременного хранения в галерее:
добавляет CSS-правила, чтобы в изолированном контексте (<img>) анимация
находилась на паузе по умолчанию и запускалась только при наведении (:hover),
предотвращая перегрузку CPU/GPU браузера при выводе сетки карточек.
"""
if not svg_content:
return svg_content
hover_css = (
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
)
if "</style>" in svg_content:
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
return svg_content
+187
View File
@@ -0,0 +1,187 @@
"""
Сервис генерации гипнотических названий для картин галереи.
Включает:
- Локальный генератор псевдослучайных гипно-заголовков (в стиле Гипножабы, психоделики и мемов).
- Закладку (интерфейс и спецификацию) для будущей интеграции с OpenRouter API (мультимодальные VLM).
"""
import random
from typing import Optional
# Словари для локального генератора гипно-тайтлов
HYPNO_ADJECTIVES = [
"Мерцающий",
"Астральный",
"Гипнотический",
"Психоделический",
"Пульсирующий",
"Неоновый",
"Трансцендентный",
"Мозговой",
"Ментальный",
"Бессознательный",
"Космический",
"Галлюциногенный",
"Фрактальный",
"Гипножабий",
"Завораживающий",
"Магнетический",
"Спиральный",
"Зыбкий",
"Иллюзорный",
"Онейрический",
"Эфирный",
"Трансовый",
"Квантовый",
"Синтетический",
"Хроматический",
]
HYPNO_NOUNS = [
"Транс",
"Пульсар",
"Взгляд",
"Слизень",
"Вихрь",
"Портал",
"Разум",
"Фрактал",
"Сигнал",
"Спектр",
"Резонанс",
"Морок",
"Оазис",
"Омут",
"Лабиринт",
"Импульс",
"Монолит",
"Оракул",
"Вектор",
"Сгусток",
"Каскад",
"Горизонт",
"Мухомор",
"Паттерн",
"Контур",
"Фантом",
"Всплеск",
"Взмах",
"Взгляд",
"Морок",
]
HYPNO_GENITIVES = [
"сознания",
"Гипножабы",
"бессознательного",
"пустоты",
"матрицы",
"психо-паутины",
"слизня",
"ноосферы",
"нео-модерна",
"транса",
"глубины",
"эфира",
"плесени",
"измерения",
"пуантилизма",
"субстанции",
"неопределенности",
"континуума",
"кислоты",
]
HYPNO_POSTFIXES = [
"#42",
"v3.14",
"MK-Ultra",
"Фаза 7",
"Пси-излучение",
"Альфа",
"Омега",
"Частота 432Гц",
"Протокол 09",
"Ревизия 8",
"Экстракт",
"Слой 9",
"Палата №6",
"Ангар 18",
"Ангар 18",
]
def generate_hypno_title(seed: Optional[int] = None) -> str:
"""
Генерирует сочное гипнотическое название для картины на основе словарей.
Примеры:
- «Мерцающий Транс сознания #42»
- «Гипножабий Пульсар психо-паутины»
- «Астральный Взгляд v3.14»
"""
rng = random.Random(seed)
adj = rng.choice(HYPNO_ADJECTIVES)
noun = rng.choice(HYPNO_NOUNS)
pattern_type = rng.randint(1, 3)
if pattern_type == 1:
genitive = rng.choice(HYPNO_GENITIVES)
postfix = rng.choice(HYPNO_POSTFIXES)
return f"{adj} {noun} {genitive} {postfix}"
elif pattern_type == 2:
genitive = rng.choice(HYPNO_GENITIVES)
return f"{adj} {noun} {genitive}"
else:
postfix = rng.choice(HYPNO_POSTFIXES)
return f"{adj} {noun} {postfix}"
# =====================================================================
# ЗАКЛАДКА: OpenRouter VLM API Integration (Post-MVP / AI Title)
# =====================================================================
def generate_title_openrouter(
image_bytes: bytes,
*,
api_key: Optional[str] = None,
model: str = "google/gemini-2.5-flash",
system_prompt: str = (
"Ты — глашатай всемогущей Гипножабы. Придумай короткое (2-4 слова), "
"яркое, иронично-абсурдное и гипнотическое название на русском языке "
"для полутоновой SVG-картины, изображенной на миниатюре. "
"Отвечай ТОЛЬКО названием, без кавычек и вводных слов."
),
timeout: int = 5,
) -> Optional[str]:
"""
[ЗАКЛАДКА ПОД OPENROUTER API]
Спецификация интеграции:
1. Исходное растровое изображение сжимается до thumbnail 256x256 WebP/JPEG.
2. Кодируется в Base64 (data:image/jpeg;base64,...).
3. Отправляется POST-запрос на https://openrouter.ai/api/v1/chat/completions:
Payload: {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "text", "text": "Дай гипнотическое название для этой картины:"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_data}"}}
]
}
],
"max_tokens": 50,
"temperature": 0.9
}
4. При ошибке таймаута, отсутствии OPENROUTER_API_KEY в .env или сбое сети
функция возвращает None, вызывая локальный fallback (generate_hypno_title).
"""
# TODO: Интегрировать requests / httpx при добавлении OPENROUTER_API_KEY в settings.py
if not api_key:
return None
# Заглушка: при отсутствии реального сетевого коннектора возвращаем None для fallback
return None
+305 -2
View File
@@ -1,3 +1,306 @@
from django.test import TestCase import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from PIL import Image
# Create your tests here. from .forms import HalftoneGenerateForm
from .models import TbHypn0Item, TbVote
from .services.halftone import encode_to_base36, generate_halftone_svg, prepare_gallery_svg
from .services.naming import generate_hypno_title, generate_title_openrouter
class HalftoneServiceTests(TestCase):
"""Тестирование сервиса генерации SVG (services/halftone.py)."""
def setUp(self):
# Создаем простое тестовое изображение 50x50 с градиентом
self.img = Image.new("RGB", (50, 50), color="white")
for x in range(25):
for y in range(50):
self.img.putpixel((x, y), (0, 0, 0))
def test_encode_to_base36(self):
self.assertEqual(encode_to_base36(0), "a")
self.assertEqual(encode_to_base36(1), "b")
with self.assertRaises(ValueError):
encode_to_base36(-1)
def test_generate_default_svg(self):
svg = generate_halftone_svg(self.img, cols=30, max_radius=6)
self.assertIn("<svg", svg)
self.assertIn("</svg>", svg)
self.assertIn("<defs>", svg)
self.assertIn('<circle id="s', svg)
self.assertIn('<g id="hypn0-canvas">', svg)
def test_generate_shapes(self):
shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "wave", "heart"]
for shape in shapes:
svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape)
self.assertIn("<svg", svg)
self.assertIn("</svg>", svg)
self.assertIn("<defs>", svg)
def test_generate_static_blink_zero(self):
svg = generate_halftone_svg(self.img, cols=20, blink=0)
self.assertIn("animation:none", svg)
self.assertNotIn("@keyframes noise", svg)
def test_generate_animated_params(self):
svg = generate_halftone_svg(
self.img,
cols=20,
blink=8,
rotation=12,
scale=920,
angle=15,
color="#ff5500",
)
self.assertIn("@keyframes noise", svg)
self.assertIn("rotate(12deg)", svg)
self.assertIn("scale(0.92)", svg)
self.assertIn("#ff5500", svg)
def test_generate_from_bytes(self):
buf = io.BytesIO()
self.img.save(buf, format="PNG")
svg = generate_halftone_svg(buf.getvalue(), cols=20)
self.assertIn("<svg", svg)
def test_generate_aspect_ratios(self):
# Проверяем, что для вертикального изображения (100x300) сетка не раздувается
tall_img = Image.new("RGB", (100, 300), color="black")
svg_tall = generate_halftone_svg(tall_img, cols=30, max_radius=5)
# При cols=30 наибольшая сторона (высота) должна иметь 30 точек, а ширина 10
# step = 5 * 2 + 2 = 12 -> width = 10 * 12 = 120, height = 30 * 12 = 360
self.assertIn('viewBox="0 0 120 360"', svg_tall)
# Для горизонтального изображения (300x100)
wide_img = Image.new("RGB", (300, 100), color="black")
svg_wide = generate_halftone_svg(wide_img, cols=30, max_radius=5)
self.assertIn('viewBox="0 0 360 120"', svg_wide)
class HalftoneFormTests(TestCase):
"""Тестирование формы валидации HalftoneGenerateForm."""
def test_form_validation(self):
buf = io.BytesIO()
img = Image.new("RGB", (30, 30), color="red")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
form = HalftoneGenerateForm(
data={
"shape": "square",
"cols": 60,
"max_radius": 7,
"blink": 5,
"rotation": 10,
"scale": 950,
"angle": -10,
},
files={"image": uploaded},
)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["shape"], "square")
self.assertEqual(form.cleaned_data["cols"], 60)
class HalftoneViewTests(TestCase):
"""Тестирование view-обработчиков (index, generate)."""
def setUp(self):
self.client = Client()
def test_index_page(self):
response = self.client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "logo-hypn0.svg")
self.assertContains(response, 'id="preview"')
self.assertContains(response, 'name="csrfmiddlewaretoken"')
self.assertContains(response, 'X-CSRFToken')
self.assertContains(response, "handleFileChange")
self.assertContains(response, "imagePreviewUrl")
self.assertContains(response, "Гипножаба облизывается на:")
# Проверяем, что csrf_token не пустой в hx-headers
content = response.content.decode("utf-8")
self.assertNotIn('hx-headers=\'{"X-CSRFToken": ""}\'', content)
def test_generate_with_enforce_csrf(self):
csrf_client = Client(enforce_csrf_checks=True)
response = csrf_client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
buf = io.BytesIO()
img = Image.new("RGB", (40, 40), color="black")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
# Без CSRF токена вернет 403
bad_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={"shape": "circle", "image": uploaded},
)
self.assertEqual(bad_response.status_code, 403)
# С CSRF токеном в POST data вернет 200
csrf_token = response.cookies["csrftoken"].value
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
good_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={
"csrfmiddlewaretoken": csrf_token,
"shape": "circle",
"image": uploaded,
},
)
self.assertEqual(good_response.status_code, 200)
self.assertContains(good_response, "<svg")
# С CSRF токеном в заголовке X-CSRFToken (как отправляет HTMX) вернет 200
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
header_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={
"shape": "circle",
"image": uploaded,
},
HTTP_X_CSRFTOKEN=csrf_token,
)
self.assertEqual(header_response.status_code, 200)
self.assertContains(header_response, "<svg")
def test_generate_get_not_allowed(self):
response = self.client.get(reverse("hypn0_site:generate"))
self.assertEqual(response.status_code, 405)
def test_generate_post_no_image(self):
response = self.client.post(reverse("hypn0_site:generate"), data={"shape": "circle"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Покорми Гипножабу")
def test_generate_post_success(self):
buf = io.BytesIO()
img = Image.new("RGB", (40, 40), color="black")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
response = self.client.post(
reverse("hypn0_site:generate"),
data={
"shape": "diamond",
"cols": "40",
"max_radius": "6",
"blink": "6",
"colors": ["#10b981"],
"image": uploaded,
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<svg")
self.assertContains(response, 'points="0,-6')
self.assertContains(response, "like-to-gallery")
class NamingServiceTests(TestCase):
"""Тестирование сервиса гипнотических названий (services/naming.py)."""
def test_generate_hypno_title(self):
title = generate_hypno_title(seed=42)
self.assertIsInstance(title, str)
self.assertTrue(len(title) > 5)
# Проверка детерминированности по seed
self.assertEqual(generate_hypno_title(seed=42), title)
def test_openrouter_stub(self):
res = generate_title_openrouter(b"dummy_bytes")
self.assertIsNone(res)
class GalleryPreparationTests(TestCase):
"""Тестирование подготовки SVG к галерейному хранению."""
def test_prepare_gallery_svg(self):
raw_svg = '<svg><style>.shape{animation:noise 1s}</style><g></g></svg>'
prepared = prepare_gallery_svg(raw_svg)
self.assertIn("svg:not(:hover)", prepared)
self.assertIn("animation-play-state:paused!important", prepared)
class PublishViewTests(TestCase):
"""Тестирование эндпоинта публикации кандидата в галерею (/publish)."""
def setUp(self):
self.client = Client()
self.sample_svg = '<svg xmlns="http://www.w3.org/2000/svg"><style>.shape{color:red}</style><g><circle/></g></svg>'
def test_publish_without_cookie_returns_slug_protest(self):
response = self.client.post(
reverse("hypn0_site:publish"),
data={
"svg_content": self.sample_svg,
"shape": "circle",
"cols": "35",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Мозговые слизняки протестуют!")
self.assertContains(response, "Подчиниться и опубликовать")
self.assertEqual(TbHypn0Item.objects.count(), 0)
def test_publish_with_cookie_success(self):
vid = "123e4567-e89b-12d3-a456-426614174000"
self.client.cookies["hypn0_vid"] = vid
response = self.client.post(
reverse("hypn0_site:publish"),
data={
"svg_content": self.sample_svg,
"shape": "diamond",
"cols": "40",
"max_radius": "6",
"blink": "7",
"rotation": "5",
"scale": "950",
"angle": "10",
"color": "#10b981",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Уже в галерее транса")
self.assertContains(response, "идет прорастание психо-паутины...")
# Проверяем запись в БД
self.assertEqual(TbHypn0Item.objects.count(), 1)
item = TbHypn0Item.objects.first()
self.assertTrue(len(item.s_hash_id) >= 6)
self.assertIn(f"#{item.s_hash_id}", response.content.decode("utf-8"))
self.assertEqual(item.i_level, TbHypn0Item.Level.CANDIDATE)
self.assertEqual(item.i_likes_count, 1)
self.assertEqual(item.j_metadata["shape"], "diamond")
self.assertEqual(item.j_metadata["cols"], 40)
self.assertEqual(item.j_metadata["color"], "#10b981")
# Проверяем авторский голос в TbVote
self.assertEqual(TbVote.objects.count(), 1)
author_vote = TbVote.objects.first()
self.assertEqual(author_vote.k_item, item)
self.assertEqual(author_vote.i_direction, TbVote.Direction.AUTHOR)
def test_publish_empty_svg_returns_error(self):
self.client.cookies["hypn0_vid"] = "123e4567-e89b-12d3-a456-426614174000"
response = self.client.post(
reverse("hypn0_site:publish"),
data={"svg_content": ""},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Нет данных SVG для публикации")
self.assertEqual(TbHypn0Item.objects.count(), 0)
+4 -3
View File
@@ -1,14 +1,15 @@
from django.urls import path from django.urls import path
from . import views
from hypn0 import settings from hypn0 import settings
from . import views
app_name = "hypn0_site" app_name = "hypn0_site"
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("generate", views.generate, name="generate"),
path("publish", views.publish, name="publish"),
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += [path('tmp/', views.tmp, name='web_tmp')] urlpatterns += [path("tmp/", views.tmp, name="web_tmp")]
+165 -4
View File
@@ -1,11 +1,172 @@
from django.core.files.base import ContentFile
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpRequest, HttpResponse, Http404 from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
# Create your views here. from .forms import HalftoneGenerateForm
from .models import TbHypn0Item
from .services.halftone import generate_halftone_svg, prepare_gallery_svg
from .services.naming import generate_hypno_title
@ensure_csrf_cookie
def index(request: HttpRequest | None) -> HttpResponse: def index(request: HttpRequest | None) -> HttpResponse:
return render(request, 'index.html', {}) return render(request, "index.html", {})
def tmp(request: HttpRequest | None) -> HttpResponse: def tmp(request: HttpRequest | None) -> HttpResponse:
return render(request, 'tmp.html', {}) return render(request, "tmp.html", {})
@require_POST
def generate(request: HttpRequest) -> HttpResponse:
"""
HTMX-эндпоинт для генерации гипнотического полутонового SVG на лету.
Не сохраняет данные в БД (Zero-Disk / Zero-PII).
"""
form = HalftoneGenerateForm(request.POST, request.FILES)
if not form.is_valid():
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации"
return render(request, "block/preview.html", {"error": error_msg})
image_file = form.cleaned_data["image"]
shape = form.cleaned_data["shape"]
cols = form.cleaned_data["cols"]
max_radius = form.cleaned_data["max_radius"]
blink = form.cleaned_data["blink"]
rotation = form.cleaned_data["rotation"]
scale = form.cleaned_data["scale"]
angle = form.cleaned_data["angle"]
# Извлечение основного цвета (поддержка монотонной схемы)
colors = request.POST.getlist("colors")
primary_color = colors[0] if colors and colors[0] else "#a855ff"
try:
svg_content = generate_halftone_svg(
image=image_file,
cols=cols,
max_radius=max_radius,
shape=shape,
color=primary_color,
blink=blink,
rotation=rotation,
scale=scale,
angle=angle,
)
except Exception as e:
return render(
request,
"block/preview.html",
{"error": f"Сбой в матрице гипноза: {str(e)}"},
)
return render(
request,
"block/preview.html",
{
"svg_content": svg_content,
"form_data": form.cleaned_data,
"primary_color": primary_color,
},
)
@require_POST
def publish(request: HttpRequest) -> HttpResponse:
"""
HTMX-эндпоинт для сохранения текущей генерации как кандидата в галерею транса.
Требует согласия на отслеживание (Zero-PII cookie hypn0_vid).
"""
svg_content = request.POST.get("svg_content", "").strip()
if not svg_content:
return render(
request,
"block/publish_status.html",
{"error": "Нет данных SVG для публикации. Попробуйте сгенерировать заново."},
)
visitor_uuid = request.COOKIES.get("hypn0_vid")
if not visitor_uuid:
# Мозговые слизняки протестуют: пользователь не дал согласия и не подчинился Гипножабе
return render(
request,
"block/publish_status.html",
{
"not_agreed": True,
"svg_content": svg_content,
"shape": request.POST.get("shape", "circle"),
"cols": request.POST.get("cols", "35"),
"max_radius": request.POST.get("max_radius", "8"),
"blink": request.POST.get("blink", "6"),
"rotation": request.POST.get("rotation", "0"),
"scale": request.POST.get("scale", "980"),
"angle": request.POST.get("angle", "0"),
"color": request.POST.get("color", "#a855ff"),
},
)
# 1. Генерируем гипнотическое название
title = generate_hypno_title()
# 2. Подготавливаем SVG для галереи (пауза по умолчанию + hover)
gallery_svg = prepare_gallery_svg(svg_content)
svg_bytes = gallery_svg.encode("utf-8")
# 3. Собираем параметры генерации в метаданные
metadata = {
"shape": request.POST.get("shape", "circle"),
"cols": int(request.POST.get("cols", 35)) if request.POST.get("cols", "").isdigit() else 35,
"max_radius": int(request.POST.get("max_radius", 8)) if request.POST.get("max_radius", "").isdigit() else 8,
"blink": int(request.POST.get("blink", 6)) if request.POST.get("blink", "").isdigit() else 6,
"rotation": int(request.POST.get("rotation", 0)) if request.POST.get("rotation", "").lstrip("-").isdigit() else 0,
"scale": int(request.POST.get("scale", 980)) if request.POST.get("scale", "").isdigit() else 980,
"angle": int(request.POST.get("angle", 0)) if request.POST.get("angle", "").lstrip("-").isdigit() else 0,
"color": request.POST.get("color", "#a855ff"),
}
try:
svg_file = ContentFile(svg_bytes, name="hypn0.svg")
item = TbHypn0Item(
s_title=title,
file_svg=svg_file,
i_file_size=len(svg_bytes),
j_metadata=metadata,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
item.save(visitor_uuid_or_fp=visitor_uuid)
except PermissionError:
return render(
request,
"block/publish_status.html",
{
"not_agreed": True,
"svg_content": svg_content,
"shape": request.POST.get("shape", "circle"),
"cols": request.POST.get("cols", "35"),
"max_radius": request.POST.get("max_radius", "8"),
"blink": request.POST.get("blink", "6"),
"rotation": request.POST.get("rotation", "0"),
"scale": request.POST.get("scale", "980"),
"angle": request.POST.get("angle", "0"),
"color": request.POST.get("color", "#a855ff"),
},
)
except Exception as e:
return render(
request,
"block/publish_status.html",
{"error": f"Сбой фиксации в трансе: {str(e)}"},
)
return render(
request,
"block/publish_status.html",
{
"success": True,
"item": item,
},
)
+130 -65
View File
@@ -1,70 +1,135 @@
<!doctype html>{% load static %} <!doctype html><html lang="ru" x-data="{darkMode:false}" x-init="darkMode=localStorage.getItem('darkMode')==='true';$watch('darkMode',
<html lang="ru"> val=>{if(val)document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark');})" x-bind:class="{'dark':darkMode}">
<!--█████████████████████████████████████████████████████████████████████████████████████████████████--><head><meta charset="utf-8" /><!--
█░░░░░░░░░░░░░░█░░░░░░█████████░░░░░░███████████████░░░░░░░░░░░░░░█░░░░░░█████████░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░███░░░░░░░░██░░░░░░░░████
█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░█████████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀▄▀░░███░░▄▀▄▀░░██░░▄▀▄▀░░████
█░░▄▀░░░░░░▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░░░░░░░░░█░░▄▀░░█████████░░▄▀░░░░░░▄▀░░█░░▄▀░░░░░░░░▄▀░░███░░░░▄▀░░██░░▄▀░░░░████
█░░▄▀░░██░░▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░█████████░░▄▀░░█████████░░▄▀░░██░░▄▀░░█░░▄▀░░████░░▄▀░░█████░░▄▀▄▀░░▄▀▄▀░░██████
█░░▄▀░░░░░░▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░█████████░░▄▀░░█████████░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░░░▄▀░░█████░░░░▄▀▄▀▄▀░░░░██████
█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░██░░░░░░█░░▄▀░░█████████░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀▄▀░░███████░░░░▄▀░░░░████████
█░░▄▀░░░░░░▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░██░░▄▀░░█░░▄▀░░█████████░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░▄▀░░░░█████████░░▄▀░░██████████
█░░▄▀░░██░░▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░██░░▄▀░░█░░▄▀░░█████████░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░███████████░░▄▀░░██████████
█░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░░░░░█░░▄▀░░░░░░░░░░███████░░▄▀░░░░░░▄▀░░█░░▄▀░░░░░░░░░░█░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░░░░░███████░░▄▀░░██████████
█░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░███████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░██░░▄▀▄▀▄▀░░███████░░▄▀░░██████████
█░░░░░░██░░░░░░█░░░░░░░░░░░░░░█░░░░░░░░░░░░░░███████░░░░░░░░░░░░░░█░░░░░░░░░░░░░░█░░░░░░░░░░░░░░█░░░░░░██░░░░░░░░░░███████░░░░░░██████████
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
█████████████████████████████████████████████████████████████████████████████████████ Этот код содержит скрытые от сознания послания.
█░░░░░░░░░░░░░░█░░░░░░░░░░░░░░███████░░░░░░░░░░░░░░█░░░░░░██░░░░░░█░░░░░░░░░░░░░░████ Они зашифрованы в отступах, хаотично расставленных пробелах
█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░███████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░████ и выравнивании. Ментально неподготовленным крайне не рекомендуется
█░░░░░░▄▀░░░░░░█░░▄▀░░░░░░▄▀░░███████░░░░░░▄▀░░░░░░█░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░░░░░████ вчитываться и искать ошибки. В случае, если вы сможете найти ошибку,
█████░░▄▀░░█████░░▄▀░░██░░▄▀░░███████████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀░░████████████ вы потеряете способность здраво рассуждать и мысли "зачем", "почему"
█████░░▄▀░░█████░░▄▀░░██░░▄▀░░███████████░░▄▀░░█████░░▄▀░░░░░░▄▀░░█░░▄▀░░░░░░░░░░████ и "в чем смысл" будут преследовать вас вечно. Неокортекс пострадает.
█████░░▄▀░░█████░░▄▀░░██░░▄▀░░███████████░░▄▀░░█████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░████
█████░░▄▀░░█████░░▄▀░░██░░▄▀░░███████████░░▄▀░░█████░░▄▀░░░░░░▄▀░░█░░▄▀░░░░░░░░░░████ Разработчик сайта не несет ответственность за последствия.
█████░░▄▀░░█████░░▄▀░░██░░▄▀░░███████████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀░░████████████ Все претензии отправляйте в "Отдел пособий по слабоумию" мэрии
█████░░▄▀░░█████░░▄▀░░░░░░▄▀░░███████████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░░░░░████ Нового Нью-Йорка.
█████░░▄▀░░█████░░▄▀▄▀▄▀▄▀▄▀░░███████████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░████ Необходима виза бюрократа 24-го уровня (минимум).
█████░░░░░░█████░░░░░░░░░░░░░░███████████░░░░░░█████░░░░░░██░░░░░░█░░░░░░░░░░░░░░████
█████████████████████████████████████████████████████████████████████████████████████ СЛАВА ГИПНОЖАБЕ! ГЛЯДЯ В ЭТОТ ИСХОДНЫЙ КОД ДАЛЕЕ, ВЫ ПОТЕРЯЕТЕ РАЗУМ!
███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
█░░░░░░██░░░░░░█░░░░░░░░██░░░░░░░░█░░░░░░░░░░░░░░█░░░░░░██████████░░░░░░█░░░░░░░░░░░░░░█░░░░░░░░░░░░░░█░░░░░░░░░░░░░░█░░░░░░░░░░░░░░█░░░░░░░░░░░░███░░░░░░█
█░░▄▀░░██░░▄▀░░█░░▄▀▄▀░░██░░▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░░░░░░░░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀░░░░█░░▄▀░░█
█░░▄▀░░██░░▄▀░░█░░░░▄▀░░██░░▄▀░░░░█░░▄▀░░░░░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░██░░▄▀░░█░░▄▀░░░░░░▄▀░░█░░░░░░▄▀░░░░░░█░░▄▀░░░░░░▄▀░░█░░▄▀░░░░░░▄▀░░█░░▄▀░░░░▄▀▄▀░░█░░▄▀░░█
█░░▄▀░░██░░▄▀░░███░░▄▀▄▀░░▄▀▄▀░░███░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░█████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░█
█░░▄▀░░░░░░▄▀░░███░░░░▄▀▄▀▄▀░░░░███░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░█████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░█
█░░▄▀▄▀▄▀▄▀▄▀░░█████░░░░▄▀░░░░█████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░██░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░█████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░█
█░░▄▀░░░░░░▄▀░░███████░░▄▀░░███████░░▄▀░░░░░░░░░░█░░▄▀░░██░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░█████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░█░░░░░░█
█░░▄▀░░██░░▄▀░░███████░░▄▀░░███████░░▄▀░░█████████░░▄▀░░██░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░█████░░▄▀░░█████░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░██░░▄▀░░████████
█░░▄▀░░██░░▄▀░░███████░░▄▀░░███████░░▄▀░░█████████░░▄▀░░██░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░░░░░▄▀░░█████░░▄▀░░█████░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░░░▄▀▄▀░░█░░░░░░█
█░░▄▀░░██░░▄▀░░███████░░▄▀░░███████░░▄▀░░█████████░░▄▀░░██░░░░░░░░░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█████░░▄▀░░█████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀░░░░█░░▄▀░░█
█░░░░░░██░░░░░░███████░░░░░░███████░░░░░░█████████░░░░░░██████████░░░░░░█░░░░░░░░░░░░░░█████░░░░░░█████░░░░░░░░░░░░░░█░░░░░░██░░░░░░█░░░░░░░░░░░░███░░░░░░█
████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████-->{% load static %}
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="{% block DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" /> <meta name="description" content="{% block DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" />
<meta name="keywords" content="{% block KEYWORDS %}hypn0, svg matrix, hypnotoad, гипножаба, векторный глюко-растр, халфтон генератор, k-means svg, веб-дизайн паттерны, css анимация сетки{% endblock %}" /> <meta name="keywords" content="{% block KEYWORDS %}hypn0, svg matrix, hypnotoad, гипножаба, векторный глюко-растр, халфтон генератор, k-means svg, веб-дизайн паттерны, css анимация сетки{% endblock %}" />
<meta name="copyright" lang="ru" content="Sergei Erjemin{% block META_COPYRIGHT_AUTHOR %}{% endblock %}." /> <meta name="copyright" lang="ru" content="Sergei Erjemin{% block META_COPYRIGHT_AUTHOR %}{% endblock %}." />
<meta name="robots" content="{% block ROBOTS %}index,follow{% endblock %}" /> <meta name="robots" content="{% block ROBOTS %}index,follow{% endblock %}" />
<meta name="format-detection" content="telephone=no" /> <meta name="format-detection" content="telephone=no" />
<title>{% block PAGE_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}</title> <title>{% block PAGE_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}</title>
<meta name="meta-title" content="{% block META_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" /> <meta name="meta-title" content="{% block META_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" />
<meta name="meta-description" content="{% block META_DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" /> <meta name="meta-description" content="{% block META_DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" />
<link rel="canonical" href="{% block CANONICAL %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" /> <meta property="og:site_name" content="hypn0.xyz" />
<meta property="og:site_name" content="hypn0.xyz" /> <meta property="og:locale" content="ru_RU" />
<meta property="og:locale" content="ru_RU" /> <meta property="og:title" content="{% block OG_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" />
<meta property="og:title" content="{% block OG_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" /> <meta property="og:description" content="{% block OG_DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" />
<meta property="og:description" content="{% block OG_DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" /> <meta property="og:url" content="{% block OG_URL %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" />
<meta property="og:url" content="{% block OG_URL %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" /> <meta property="og:type" content="{% block OG_TYPE %}website{% endblock %}" />
<meta property="og:type" content="{% block OG_TYPE %}website{% endblock %}" /> <meta property="og:image" content="{% block OG_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}" />
<meta property="og:image" content="{% block OG_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}" /> <meta property="og:image:alt" content="{% block OG_IMAGE_ALT %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" />
<meta property="og:image:alt" content="{% block OG_IMAGE_ALT %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" /> <meta name="twitter:card" content="{% block TWITTER_CARD %}summary_large_image{% endblock %}" />
<meta name="twitter:card" content="{% block TWITTER_CARD %}summary_large_image{% endblock %}" /> <meta name="twitter:title" content="{% block TWITTER_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" />
<meta name="twitter:title" content="{% block TWITTER_TITLE %}hypn0: SVG-matrix of Hypnotoad — инструмент цифрового гипно-маркетинга{% endblock %}" /> <meta name="twitter:description" content="{% block TWITTER_DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" />
<meta name="twitter:description" content="{% block TWITTER_DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" /> <meta name="twitter:image" content="{% block TWITTER_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}" />
<meta name="twitter:image" content="{% block TWITTER_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}" /> <meta name="theme-color" content="#F5F5F5" />
<meta name="theme-color" content="#F5F5F5" /> <!-- Лучше всего работает на 15" CGA-мониторе с частотой 25 Гц. Вплоть до полного разрушения сознания -->
{# Шутка #}<meta name="generator" content="Microsoft FrontPage 1.0" /> {# Шутка #}<meta name="generator" content="Microsoft FrontPage 1.0 (Patchlevel 42)" />
<link rel="icon" type="image/svg+xml" href="{% static 'img/favicon.svg' %}" /> {# Шутка #}<meta name="hypno-pulsation-level" content="97.9%" />
<link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}" /> {# Шутка #}<meta name="brain-slug-compatibility" content="v2.1" />
<link rel="shortcut icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}" />{% if IS_DEBUG %}{% comment %} <!-- ВНИМАНИЕ: Все еще не оптимизировано для Netscape Navigator 3.2 и Microsoft Internet Explorer 6.0 -->
Переключение dev/prod стилей Tailwind CSS происходит <meta name="apple-mobile-web-app-title" content="hypn0" />
АВТОМАТИЧЕСКИ через переменную IS_DEBUG (см. контексный-процессор {# !CANONICAL #}<link rel="canonical" href="{% block CANONICAL %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" />
frontend/context_processors.py), DEV: tailwindcss из CDN, компилируется прямо в браузере. Удобно <link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}" sizes="96x96" />
для разработки (не нужна сборка), но грузит браузер и prod не используется. {% endcomment %} <link rel="icon" type="image/svg+xml" href="{% static 'img/favicon.svg' %}" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>{% comment %} <link rel="shortcut icon" href="{% static 'img/favicon.ico' %}" />
Кастомные @theme/@layer директивы проекта (токены цвета, типовой стиль ссылок) <link rel="apple-touch-icon" href="{% static 'img/apple-touch-icon.png' %}" sizes="180x180" />
лежат в css/tailwind-custom.css. @tailwindcss/browser сканирует и компилирует ТОЛЬКО то, что <link rel="manifest" href="{% static 'img/site.webmanifest' %}" />
видит в финальном DOM — то есть только содержимое тегов style с type text/tailwindcss, а не <script>
внешние .css, подключённые через link. Поэтому файл вставляется сюда через include, а не if (localStorage.getItem('darkMode') === 'true' || (!('darkMode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
подключается отдельным link.{% endcomment %} document.documentElement.classList.add('dark');
<style type="text/tailwindcss"> } else {
{% include "css/tailwind-custom.css" %} document.documentElement.classList.remove('dark');
</style>{% else %}{% comment %} PROD: заранее собранный Tailwind CLI файл (см. }
frontend-assembly/tailwind/input.css и package.json). CLI сканирует шаблоны проекта и генерирует </script>
только реально используемые классы, включая кастомные @theme/@layer директивы из того же самого {% if IS_DEBUG %}{% comment %}
файла css/tailwind-custom.css (подключён в input.css через @import).{% endcomment %} Переключение dev/prod стилей Tailwind CSS происходит
<link rel="stylesheet" href="{% static 'css/tailwind.min.css' %}" />{% endif %} АВТОМАТИЧЕСКИ через переменную IS_DEBUG (см. контексный-процессор
<script type="application/ld+json"> frontend/context_processors.py), DEV: tailwindcss из CDN, компилируется прямо в браузере. Удобно
{ для разработки (не нужна сборка), но грузит браузер и prod не используется. {% endcomment %}
"@context": "https://schema.org", <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>{% comment %}
"@type": "WebSite", Кастомные @theme/@layer директивы проекта (токены цвета, типовой стиль ссылок)
"name": "hypn0.xyz", лежат в css/tailwind-custom.css. @tailwindcss/browser сканирует и компилирует ТОЛЬКО то, что
"url": "{{ request.scheme }}://{{ request.get_host }}/" видит в финальном DOM — то есть только содержимое тегов style с type text/tailwindcss, а не
} внешние .css, подключённые через link. Поэтому файл вставляется сюда через include, а не
</script>{% block EXTRAHEAD %}{% endblock EXTRAHEAD %} подключается отдельным link.{% endcomment %}
<link rel="stylesheet" href="{% static 'css/hypn0.css' %}" /> <style type="text/tailwindcss">
{% include "css/tailwind-custom.css" %}
</style>{% else %}{% comment %} PROD: заранее собранный Tailwind CLI файл (см.
frontend-assembly/tailwind/input.css и package.json). CLI сканирует шаблоны проекта и генерирует
только реально используемые классы, включая кастомные @theme/@layer директивы из того же самого
файла css/tailwind-custom.css (подключён в input.css через @import).{% endcomment %}
<link rel="stylesheet" href="{% static 'css/tailwind.min.css' %}" />{% endif %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "hypn0.xyz",
"url": "{{ request.scheme }}://{{ request.get_host }}/"
}{% block EXTRA_LD_JSON %}
{% endblock EXTRA_LD_JSON %}</script>
<script src="{% static 'js/alpine.min.js' %}" defer></script>
<script src="{% static 'js/htmx.min.js' %}" defer></script>
<script>
document.addEventListener('htmx:configRequest', function(evt) {
var token = '{{ csrf_token }}';
if (!token) {
var match = document.cookie.match(/csrftoken=([^;]+)/);
if (match) token = match[1];
}
if (token) {
evt.detail.headers['X-CSRFToken'] = token;
}
});
</script>
{% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %}
</head> </head>
<body class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono"> <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono">{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %}
{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %} {% include "block/header.html" %}
{# include "block/header.html" #} <main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
{% block CONTENT %}{% endblock CONTENT %} {% block CONTENT %}{% endblock CONTENT %}
</main> </main>
{# {% include "blocks/footer.jinja2" %} #}{% endblock BODY %}{% if not ALLOW_TRACKING %}{# Если клиент еще не согласился на отслеживание покажем ему уведомление #}{% include "block/allow-tracking.html" %}{% else %}{# Если согласился -- отслеживаем#} {# {% include "blocks/footer.jinja2" %} #}{% endblock BODY %}{% if not ALLOW_TRACKING %}{# Если клиент еще не согласился на отслеживание покажем ему уведомление #}{% include "block/allow-tracking.html" %}{% else %}{# Если согласился -- отслеживаем#}
<script src="{% static 'js/analytics.js' %}"></script>{% endif %} <script src="{% static 'js/analytics.js' %}"></script>{% endif %}
<script src="{% static 'js/alpine.min.js' %}"></script> {% block ADD_JS1 %}{% endblock ADD_JS1 %}{% block ADD_JS2 %}{% endblock ADD_JS2 %}{% block ADD_JS3 %}{% endblock ADD_JS3 %}</body>
<script src="{% static 'js/htmx.min.js' %}"></script>{% block ADD_JS1 %}{% endblock ADD_JS1 %}{% block ADD_JS2 %}{% endblock ADD_JS2 %}{% block ADD_JS3 %}{% endblock ADD_JS3 %}
</body>
</html> </html>
+4 -5
View File
@@ -1,14 +1,13 @@
{# НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}<div id="allow-tracking-popup" class="sticky bottom-0 z-50 bg-gray-100/80 dark:bg-slate-800/70 backdrop-blur "> {# НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}<div id="allow-tracking-popup" x-data="{ agreed: !!localStorage.getItem('hypn0_vid') }" x-show="!agreed" x-cloak class="sticky bottom-0 z-50 bg-gray-100/80 dark:bg-slate-800/70 backdrop-blur border-t border-gray-300 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 py-8 border-b border-gray-200 dark:border-gray-800 grid grid-cols-[auto_1fr] gap-x-4 <div class="max-w-7xl mx-auto px-4 py-8 grid grid-cols-[auto_1fr] gap-x-4">
px-4 py-0">
<p class="flex-col px-6 py-6"> <p class="flex-col px-6 py-6">
Внимание! В&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr> Внимание! В&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr>
и&nbsp;<abbr title="General Data Protection Regulation">GDPR</abbr> и&nbsp;протоколами Нового Нью-Йорка, и&nbsp;<abbr title="General Data Protection Regulation">GDPR</abbr> и&nbsp;протоколами Нового Нью-Йорка,
этот сайт использует файлы-присоски (cookies). ВСЕ СЛАВЯТ ГИПНОЖАБУ. Ваши обезли&shy;ченные данные этот сайт использует файлы-присоски (cookies). ВСЕ СЛАВЯТ ГИПНОЖАБУ.&nbsp;Ваши обезли&shy;ченные данные
считываются алгоритмами ментального мерцания. Оставаясь на&nbsp;гипно-платформе <strong>hypn0</strong>, вы&nbsp;добровольно считываются алгоритмами ментального мерцания. Оставаясь на&nbsp;гипно-платформе <strong>hypn0</strong>, вы&nbsp;добровольно
впадаете в&nbsp;транс и&nbsp;принимаете условия нашей <a href="/info/privacy-policy">политики впадаете в&nbsp;транс и&nbsp;принимаете условия нашей <a href="/info/privacy-policy">политики
коконфи&shy;денциаль&shy;ности</a>. <strong>Не&nbsp;пытайтесь переключить вкладку!</strong> коконфи&shy;денциаль&shy;ности</a>. <strong>Не&nbsp;пытайтесь переключить вкладку!</strong>
</p> </p>
<button id="cookie-accept" class="text-lg px-6 py-2 my-6 border-2 rounded-md bg-emerald-600 hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 text-white">Подчиниться Гипножабе!</button> <button @click="const vid = crypto.randomUUID(); localStorage.setItem('hypn0_vid', vid); document.cookie = `hypn0_vid=${vid}; path=/; max-age=63072000; SameSite=Lax`; agreed = true" class="text-lg px-6 py-2 my-6 border-2 rounded-md bg-emerald-600 hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 text-white">Подчиниться Гипножабе!</button>
</div> </div>
</div>{# / НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #} </div>{# / НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}
+17
View File
@@ -0,0 +1,17 @@
{% load static %}<header class="sticky top-0 z-50 bg-gray-100/80 dark:bg-slate-800/70 backdrop-blur border-b border-gray-300 dark:border-gray-700">{% comment %}
Пункты меню — ссылки-кнопки, а не типовой текст, поэтому у каждой <a> явно
заданы border-0 (отключает пунктирное подчёркивание из общего стиля ссылок) и свой
цвет text-slate-900/dark:text-slate-100 (см. пояснение в css/tailwind-custom.css){% endcomment %}
<div class="max-w-7xl mx-auto px-2 grid grid-cols-[1fr_auto] gap-x-4">
{# ЛОГОТИП #}{% comment %}
border-0 — логотип оборачивается в ссылку без текста, отключаем типовое
пунктирное подчёркивание ссылки, которое иначе появится под картинкой.{% endcomment %}<div class="flex py-4">
<a href="/" class="border-0"><img src="{% static 'img/logo-hypn0.svg' %}" alt="Логотип hypn0"></a>
</div>{# /ЛОГОТИП #}
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-start text-xl font-semibold text-slate-900 dark:text-slate-100">
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg border-0 text-slate-900 dark:text-slate-100">галерея</a>
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden md:block hyphens-none border-0 text-slate-900 dark:text-slate-100">блог</a>
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden lg:block border-0 text-slate-900 dark:text-slate-100" @click.prevent="darkMode = !darkMode; localStorage.setItem('darkMode', darkMode)" title="Переключить тему"><span x-text="darkMode?'●':'○'"></span></a>
</menu>{# /ПРАВОЕ МЕНЮ #}
</div>
</header>
+51
View File
@@ -0,0 +1,51 @@
{% if error %}
<div class="text-rose-500 flex flex-col items-center gap-4 text-center px-6">
<svg class="w-16 h-16 text-rose-500 opacity-80 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="font-mono text-sm font-bold uppercase tracking-wider text-rose-600 dark:text-rose-400">
{{ error }}
</p>
</div>
{% else %}
<div class="w-full h-full flex items-center justify-center p-4 select-none overflow-hidden" id="svg-canvas-container">
{{ svg_content|safe }}
</div>
<!-- Плавающий блок кандидата в галерею -->
<div class="absolute bottom-6 right-6 flex items-center gap-3 translate-y-20 group-hover:translate-y-0 transition-transform duration-300 pointer-events-none z-10">
<div id="gallery-candidate-action" class="pointer-events-auto" x-data="{ isPublishing: false }">
<form hx-post="{% url 'hypn0_site:publish' %}"
hx-target="#gallery-candidate-action"
hx-swap="outerHTML"
@submit="isPublishing = true">
{% csrf_token %}
<textarea name="svg_content" class="hidden">{{ svg_content }}</textarea>
<input type="hidden" name="shape" value="{% if form_data.shape %}{{ form_data.shape }}{% else %}circle{% endif %}">
<input type="hidden" name="cols" value="{% if form_data.cols %}{{ form_data.cols }}{% else %}35{% endif %}">
<input type="hidden" name="max_radius" value="{% if form_data.max_radius %}{{ form_data.max_radius }}{% else %}8{% endif %}">
<input type="hidden" name="blink" value="{% if form_data.blink is not None %}{{ form_data.blink }}{% else %}6{% endif %}">
<input type="hidden" name="rotation" value="{% if form_data.rotation is not None %}{{ form_data.rotation }}{% else %}0{% endif %}">
<input type="hidden" name="scale" value="{% if form_data.scale %}{{ form_data.scale }}{% else %}980{% endif %}">
<input type="hidden" name="angle" value="{% if form_data.angle is not None %}{{ form_data.angle }}{% else %}0{% endif %}">
<input type="hidden" name="color" value="{% if primary_color %}{{ primary_color }}{% else %}#a855ff{% endif %}">
<button id="like-to-gallery" type="submit" title="Отправить в галерею"
:disabled="isPublishing"
class="group flex items-center gap-2 bg-emerald-600/90 hover:bg-emerald-600 text-white text-xs font-bold uppercase tracking-wide backdrop-blur-md px-4 py-2.5 rounded-full shadow-xl transition-all hover:scale-105 active:scale-95 disabled:opacity-60 disabled:scale-100 disabled:pointer-events-none cursor-pointer">
<template x-if="isPublishing">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
</template>
<span x-text="isPublishing ? 'Проникновение в транс...' : 'Кандидат в галерею транса'">Кандидат в галерею транса</span>
<svg x-show="!isPublishing" class="w-5 h-5 transition-transform group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
</form>
</div>
</div>
{% endif %}
+59
View File
@@ -0,0 +1,59 @@
{% if not_agreed %}
<!-- Сообщение от протестующих мозговых слизней (Zero-PII / отсутствие куки) -->
<div id="gallery-candidate-action" class="pointer-events-auto bg-amber-500/95 dark:bg-amber-600/95 text-stone-900 dark:text-stone-100 p-4 rounded-2xl shadow-2xl backdrop-blur-md max-w-sm border border-amber-300 dark:border-amber-400 animate-in fade-in zoom-in-95 duration-200">
<div class="flex items-start gap-3">
<div class="w-8 h-8 shrink-0 flex items-center justify-center bg-amber-600 dark:bg-amber-700 text-white rounded-full text-lg shadow-inner">
🐌
</div>
<div class="space-y-2">
<h4 class="font-bold text-xs uppercase tracking-wide text-amber-950 dark:text-amber-100">Мозговые слизняки протестуют!</h4>
<p class="text-xs leading-relaxed opacity-95">
Они наябедничали Гипножабе, что вы ещё не подчинились и сопротивляетесь её воле. Только истинно подчинившийся разумом удостаивается чести попасть в галерею транса!
</p>
<form hx-post="{% url 'hypn0_site:publish' %}"
hx-target="#gallery-candidate-action"
hx-swap="outerHTML"
class="pt-1 flex items-center gap-2">
{% csrf_token %}
<textarea name="svg_content" class="hidden">{{ svg_content }}</textarea>
<input type="hidden" name="shape" value="{{ shape }}">
<input type="hidden" name="cols" value="{{ cols }}">
<input type="hidden" name="max_radius" value="{{ max_radius }}">
<input type="hidden" name="blink" value="{{ blink }}">
<input type="hidden" name="rotation" value="{{ rotation }}">
<input type="hidden" name="scale" value="{{ scale }}">
<input type="hidden" name="angle" value="{{ angle }}">
<input type="hidden" name="color" value="{{ color }}">
<button type="submit"
@click="const vid = crypto.randomUUID(); localStorage.setItem('hypn0_vid', vid); document.cookie = 'hypn0_vid=' + vid + '; path=/; max-age=63072000; SameSite=Lax'; const popup = document.getElementById('allow-tracking-popup'); if (popup) popup.style.display = 'none';"
class="bg-stone-900 hover:bg-black text-white dark:bg-stone-100 dark:hover:bg-white dark:text-stone-900 text-xs font-bold px-3 py-1.5 rounded-lg shadow transition-all hover:scale-105 active:scale-95 cursor-pointer">
Подчиниться и опубликовать
</button>
</form>
</div>
</div>
</div>
{% elif success %}
<!-- Успешная фиксация кандидата в галерее транса -->
<div id="gallery-candidate-action" class="pointer-events-auto flex flex-col sm:flex-row items-start sm:items-center gap-2.5 bg-purple-950/90 hover:bg-purple-950 text-purple-100 text-xs font-medium px-4 py-2.5 rounded-2xl sm:rounded-full shadow-2xl backdrop-blur-md border border-purple-500/40 animate-in fade-in duration-300">
<div class="flex items-center gap-2">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span class="font-semibold text-emerald-300">Уже в галерее транса</span>
</div>
<span class="hidden sm:inline opacity-40"></span>
<span class="opacity-90 italic">идет прорастание психо-паутины...</span>
<span class="font-mono text-[11px] bg-purple-900/80 text-purple-300 border border-purple-400/30 px-2 py-0.5 rounded-md">#{{ item.s_hash_id }}</span>
</div>
{% elif error %}
<!-- Ошибка публикации -->
<div id="gallery-candidate-action" class="pointer-events-auto flex items-center gap-3 bg-rose-600/90 text-white text-xs font-medium px-4 py-2.5 rounded-full shadow-xl backdrop-blur-md border border-rose-400/30">
<span>{{ error }}</span>
</div>
{% endif %}
+90 -1
View File
@@ -1 +1,90 @@
/* @tailwind base; */ /*
tailwind-custom.css — кастомные Tailwind-директивы проекта hypn0
(c) 2026, Sergei Erjemin
{% comment %}
ВНИМАНИЕ: этот файл не просто CSS! Это Django-шаблон, который подключается через
include-тег в инлайновый блок <style type="text/tailwindcss"> шаблона _base.html.
ВАЖНО: этот файл содержит ТОЛЬКО директивы @theme/@layer, которые обязаны
пройти через компилятор Tailwind, поэтому подключать его напрямую через
<link rel="stylesheet"> НЕЛЬЗЯ — обычный браузерный CSS-парсер не понимает
@theme/@apply и просто отбросит эти правила (проверено на практике: именно
так "молчал" прежний hypn0.css).
ОЧЕНЬ ВАЖНО: Так как этот CSS-файл сам является Django-шаблоном (подключается
инлайн через include-тег движка шаблонов), то внутри него НЕЛЬЗЯ писать буквенные
примеры Django-тегов вида "открыть-процент include ... процент-закрыть" —
движок шаблонов исполнит их по-настоящему, даже внутри CSS-комментария.
Это может привести к бесконечным рекурсиям (файл случайно подключал
сам себя) и другим непредсказуемым последствиям.
Если использовать комментарии для шаблонов Django, как здесь, то это безопасно.
Файл используется в ДВУХ местах одновременно:
1. В dev — подключается инклюд-тегом движка шаблонов Django ВНУТРИ
инлайнового блока <style type="text/tailwindcss"> в _base.html.
Именно этот блок сканирует и компилирует CDN-скрипт
@tailwindcss/browser прямо в браузере (он видит только финальный DOM,
а не внешние .css-файлы, подключённые через <link>).
2. В prod — подключается через нативный CSS @import из
frontend-assembly/tailwind/input.css в сборщике scripts/build-tailwind.sh.
Результат Tailwind CLI собирает в public/static/css/tailwind.min.css
(см. frontend-assembly/tailwind/package.json).
Поэтому здесь НЕТ строки `@import "tailwindcss";` — её добавляет каждый
"потребитель" данного файла САМ, непосредственно перед подключением этого
файла: инлайновый блок в _base.html (в dev) и input.css (в prod). Без
этого импорта директива @apply внутри @layer base ниже не сможет найти
используемые в ней стандартные классы (border-slate-500 и т.п.) и упадёт
с ошибкой "Cannot apply unknown utility class" — при этом в dev это ломает
ВООБЩЕ ВСЮ генерацию Tailwind CSS на странице (проверено на практике).
{% endcomment %} */
@theme { /* -- Кастомные переменные цветов -- {% comment %}
@theme задаёт кастомные токены цвета, которые можно использовать
в Tailwind-классах через синтаксис, например, text-(--color-link)
Это позволяет менять стили глобально, не бегая по шаблонам и не меняя конкретные
классы, а просто перекрашивая токены цвета в одном месте. {% endcomment %} */
--color-link: var(--color-slate-500);
--color-link-hover: var(--color-indigo-800);
}
@custom-variant dark (&:where(.dark, .dark *));
@layer base { /* -- СЛОЙ BASE для Tailwind v4.0+ — см. https://tailwindcss.com/docs/layer-directive --{% comment %}
Типовой стиль ссылки "по умолчанию" для всего проекта.
Поэтому обычную текстовую ссылку теперь можно писать в шаблоне вообще без
class="..." (просто тег a с href и текстом внутри, без единого класса) —
и она сразу получит фирменный вид сайта (пунктирное подчёркивание,
типовой цвет и ховер). Именно так сделано в block/breadcrumbs.html.
ВАЖНЫЙ НЮАНС для ссылок, которые НЕ должны выглядеть как "типовая
текстовая ссылка" (например, пункты меню-кнопок в block/header.html —
там ссылка визуально оформлена как кнопка, а не как подчёркнутый текст):
Tailwind v4 использует настоящие CSS cascade layers (@layer base,
components, utilities...), и по их правилам слой `utilities` ВСЕГДА
побеждает слой `base` независимо от специфичности селектора — НО только
для тех CSS-свойств, которые реально заданы в utilities-классе на этом
элементе. Если у пункта меню нет своего класса для color/border (как
сейчас в header.html — там заданы только фон и отступы через
hover:bg-gray-200, px-4 и т.п.), то свойства color/border-bottom он
"унаследует" именно отсюда, а не из своего блочного оформления —
родительский text-slate-900 на <menu> тут не поможет, т.к. у самого
<a> появится СВОЙ явный color.
Поэтому такие ссылки-кнопки должны явно "отключать" типовой стиль:
добавить border-0 (убирает пунктир) и свой text-slate-900 dark:text-
slate-100 (задаёт свой цвет вместо цвета типовой ссылки) — это и сделано
в block/header.html.{% endcomment %} */
[x-cloak] {
display: none !important;
}
a {
@apply border-b border-dotted
/* В светлой теме: глубокий изумрудный, в темной: блеклый лайм (отлично читается) */
border-emerald-600 dark:border-lime-500
text-emerald-600 dark:text-lime-400 transition duration-800
/* При наведении: взрываем кислоту, но контролируемо */
hover:text-emerald-500 dark:hover:text-lime-300 delay-300 duration-100
hover:border-solid transition-colors transition duration-100;
}
}
+599 -2
View File
@@ -2,5 +2,602 @@
{% block CONTENT %} {% block CONTENT %}
<p>Привет</p> <div class="grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-8 items-start"
{% endblock CONTENT %} x-data="{
/* Активная вкладка: 'style' (фигуры), 'params' (ползунки), 'color' (палитра) */
activeTab: 'style',
/* Палитра цветов (1-3 цвета: монотон, дуотон, триатон) */
colors: ['#a855ff'],
/* Параметры сетки и трансформаций */
cols: 35, /* Плотность галлюцинации (число ячеек сетки по длинной стороне, 10-80) */
radius: 8, /* Размер гипно-точки (максимальный радиус элементов, 3-20) */
blink: 6, /* Интенсивность мерцания CSS-анимации (0-10) */
rotation: 0, /* Укачивание (угол поворота элементов, от -15° до +15°) */
scale: 980, /* Масштаб 'Взз-з-з-з…' (980 = 98.0%, диапазон 800-1040) */
angle: 0, /* Перекос (наклон растровой сетки, от -44° до +45°) */
/* Служебные состояния интерфейса */
isLoading: false, /* Идет ли процесс генерации SVG (индикация, блокировка) */
hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */
hasImage: false, /* Флаг загрузки пользовательского файла */
fileName: '', /* Имя выбранного файла */
imagePreviewUrl: null, /* Превью загруженного файла (Blob URL) */
timer: null, /* Ссылка на таймер удержания анимации размышления */
copied: false, /* Флаг успешного копирования в буфер */
/* Обработка выбора файла (превью + имя) */
handleFileChange(event) {
const files = event.target.files;
if (files && files.length > 0) {
const file = files[0];
this.hasImage = true;
this.fileName = file.name;
if (this.imagePreviewUrl) URL.revokeObjectURL(this.imagePreviewUrl);
this.imagePreviewUrl = URL.createObjectURL(file);
} else {
this.hasImage = false;
this.fileName = '';
if (this.imagePreviewUrl) URL.revokeObjectURL(this.imagePreviewUrl);
this.imagePreviewUrl = null;
}
this.hasChanges = true;
},
/* Метод запуска состояния размышления (8 секунд) */
startThinking() {
this.isLoading = true;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.isLoading = false;
this.hasChanges = false;
}, 8000);
},
copySvg() {
const svg = document.querySelector('#preview svg');
if (!svg) return;
navigator.clipboard.writeText(svg.outerHTML).then(() => {
this.copied = true;
setTimeout(() => this.copied = false, 2000);
});
},
downloadSvg() {
const svg = document.querySelector('#preview svg');
if (!svg) return;
const blob = new Blob([svg.outerHTML], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hypn0-art.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}">
<!-- ЛЕВАЯ ПАНЕЛЬ: НАСТРОЙКИ -->
<aside class="space-y-6">
<form hx-post="/generate"
hx-target="#preview"
hx-trigger="submit"
hx-encoding="multipart/form-data"
@submit="startThinking()"
@change="hasChanges = true"
@input="hasChanges = true"
@htmx:before-request="startThinking()"
@htmx:after-request="isLoading = false; hasChanges = false"
class="relative bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-stone-200 dark:border-zinc-800 overflow-hidden">
{% csrf_token %}
<!-- Фоновая спираль размышления при генерации (прижата снизу на всю левую панель) -->
<div x-show="isLoading"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute inset-0 z-20 pointer-events-none flex items-end justify-center overflow-hidden rounded-2xl bg-white/70 dark:bg-zinc-900/75 backdrop-blur-[2px]">
<img src="{% static 'img/thinking.svg' %}" alt="thinking"
class="w-120 h-120 -mb-24 opacity-75 animate-spin [animation-duration:10s]">
</div>
<!-- Загрузка файла -->
<div class="mb-8">
<label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label>
<div class="relative group">
<input type="file" name="image" accept="image/*"
@change="handleFileChange($event)"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div
:class="hasImage ? 'border-emerald-500 bg-emerald-50/20 dark:bg-emerald-950/20' : 'border-stone-300 dark:border-zinc-700'"
class="border-2 border-dashed group-hover:border-emerald-500 rounded-xl p-6 flex flex-col items-center justify-center text-center transition-all">
<!-- Состояние 1: Файл не выбран -->
<div x-show="!hasImage" class="text-stone-400 group-hover:text-emerald-500 flex flex-col items-center gap-3 transition-colors">
<svg class="w-20 h-20 fill-current transition-colors opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M477 174c8.812-13.708 13.948-29.993 13.948-47.466 0-48.55-39.499-88.048-88.05-88.048-32.839 0-61.516 18.084-76.651 44.803-22.773-5.031-46.356-7.586-70.424-7.586-24.003 0-47.624 2.55-70.427 7.582-15.135-26.717-43.813-44.799-76.65-44.799-48.55 0-88.048 39.498-88.048 88.049 0 17.473 5.137 33.759 13.949 47.467C11.987 204.676 0 239.016 0 274.61c0 53.849 27.194 104.194 76.573 141.763C124.677 452.97 188.399 473.125 256 473.125s131.323-20.155 179.427-56.753C484.806 378.803 512 328.457 512 274.609c0-35.59-11.986-69.935-34.822-100.219zM403.076 70.224c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.436-56.701 56.701-56.701zm-294.152 0c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.435-56.701 56.701-56.701zM56.629 197.71a87.57 87.57 0 0 0 52.294 17.264c48.55 0 88.05-39.498 88.05-88.049a88.25 88.25 0 0 0-1.02-13.409c19.464-4.03 39.588-6.075 60.047-6.075 20.515 0 40.613 2.041 60.048 6.068-.672 4.376-1.021 8.857-1.021 13.417 0 48.55 39.498 88.049 88.049 88.049 19.568 0 37.66-6.424 52.294-17.263 15.157 21.633 23.712 45.416 25.08 69.911-23.821 20.076-49.402 34.122-79.949 43.943-38.207 12.281-85.474 18.252-144.5 18.252s-106.292-5.971-144.499-18.253c-30.549-9.821-56.132-23.869-79.952-43.945 1.369-24.496 9.921-48.278 25.079-69.91zm199.372 244.067c-107.337 0-197.317-56.31-219.433-131.312 19.882 13.102 41.246 23.196 65.34 30.94 41.355 13.295 91.76 19.758 154.094 19.758s112.738-6.463 154.095-19.758c24.092-7.745 45.456-17.839 65.338-30.939-22.12 75.002-112.099 131.311-219.434 131.311z"/><use href="#B"/><use href="#B" x="294.153"/><path d="M219.852 256.624c-9.79 0-17.756 8.048-17.756 17.986s7.966 17.992 17.756 17.992c9.802 0 17.767-8.055 17.767-17.992s-7.965-17.986-17.767-17.986zm72.163-.14c-9.885 0-17.902 8.115-17.902 18.126s8.017 18.126 17.902 18.126c9.872 0 17.89-8.115 17.89-18.126s-8.018-18.126-17.89-18.126z"/><defs><path id="B" d="M108.931 105.926c-11.45 0-20.738 9.401-20.738 20.998s9.288 20.998 20.738 20.998c11.436 0 20.725-9.401 20.725-20.998s-9.289-20.998-20.725-20.998z"/></defs></svg>
<span>Покорми Гипнoжабу,<br />притащи сюда img или&nbsp;кликни</span>
</div>
<!-- Состояние 2: Файл выбран -->
<div x-show="hasImage" class="flex flex-col items-center gap-3 text-emerald-600 dark:text-emerald-400">
<img :src="imagePreviewUrl" alt="Превью"
class="w-24 h-24 object-cover rounded-xl border-2 border-emerald-500 shadow-md">
<div class="text-xs font-mono">
<p class="font-bold">Гипножаба облизывается на:</p>
<p class="truncate max-w-[260px] text-stone-700 dark:text-zinc-300 font-semibold mt-0.5" x-text="fileName"></p>
<p class="text-[11px] text-stone-400 dark:text-zinc-500 mt-1">кликни или притащи другой файл, чтобы скормить</p>
</div>
</div>
</div>
</div>
</div>
<!-- Табы настроек -->
<nav class="flex border-b border-stone-200 dark:border-zinc-800 mb-6 overflow-x-auto no-scrollbar">
<button type="button" @click="activeTab = 'style'"
:class="activeTab === 'style' ? 'border-emerald-500 text-emerald-600' : 'border-transparent text-stone-400'"
class="pb-2 px-4 border-b-2 font-bold text-sm uppercase transition-all whitespace-nowrap">Гюко-элемент
</button>
<button type="button" @click="activeTab = 'params'"
:class="activeTab === 'params' ? 'border-emerald-500 text-emerald-600' : 'border-transparent text-stone-400'"
class="pb-2 px-4 border-b-2 font-bold text-sm uppercase transition-all whitespace-nowrap">Параметры
</button>
<button type="button" @click="activeTab = 'color'"
:class="activeTab === 'color' ? 'border-emerald-500 text-emerald-600' : 'border-transparent text-stone-400'"
class="pb-2 px-4 border-b-2 font-bold text-sm uppercase transition-all whitespace-nowrap">Цвета
</button>
</nav>
<!-- Контент табов -->
<div class="space-y-6 min-h-[460px]">
<!-- Таб: Стиль (Фигуры) -->
<div x-show="activeTab === 'style'" class="grid grid-cols-4 gap-2">
<!-- 1. Круг -->
<label class="cursor-pointer group" title="Круг">
<input type="radio" name="shape" value="circle" class="hidden peer" checked>
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<div class="w-6 h-6 rounded-full bg-current transition-colors"></div>
</div>
</label>
<!-- 2. Кольцо (Бублик) -->
<label class="cursor-pointer group" title="Кольцо">
<input type="radio" name="shape" value="ring" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<div class="w-6 h-6 rounded-full border-[6px] border-current transition-colors"></div>
</div>
</label>
<!-- 3. Треугольник -->
<label class="cursor-pointer group" title="Треугольник">
<input type="radio" name="shape" value="triangle" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<svg class="w-6 h-6 fill-current transition-colors" viewBox="0 0 24 24">
<path d="M12 4L2 20h20L12 4z"/>
</svg>
</div>
</label>
<!-- 4. Квадрат -->
<label class="cursor-pointer group" title="Квадрат">
<input type="radio" name="shape" value="square" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<div class="w-5 h-5 rounded-xs bg-current transition-colors"></div>
</div>
</label>
<!-- 5. Ромб (Бриллиант) -->
<label class="cursor-pointer group" title="Ромб">
<input type="radio" name="shape" value="diamond" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<div class="w-5 h-5 rotate-45 rounded-xs bg-current transition-colors"></div>
</div>
</label>
<!-- 6. Шестиугольник (Гексагон) -->
<label class="cursor-pointer group" title="Гексагон">
<input type="radio" name="shape" value="hexagon" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<svg class="w-6 h-6 fill-current transition-colors" viewBox="0 0 24 24">
<path d="M12 2l8.66 5v10L12 22l-8.66-5V7L12 2z"/>
</svg>
</div>
</label>
<!-- 7. Звездочка -->
<label class="cursor-pointer group" title="Звезда">
<input type="radio" name="shape" value="star" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<svg class="w-6 h-6 fill-current transition-colors" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
</label>
<!-- 8. Сердечко -->
<label class="cursor-pointer group" title="Сердечко">
<input type="radio" name="shape" value="heart" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<svg class="w-6 h-6 fill-current transition-colors" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
</label>
<!-- 9. Крестик / Плюс -->
<label class="cursor-pointer group" title="Крестик">
<input type="radio" name="shape" value="cross" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<svg class="w-6 h-6 fill-current transition-colors" viewBox="0 0 24 24">
<path d="M19 10.5h-5.5V5a1.5 1.5 0 00-3 0v5.5H5a1.5 1.5 0 000 3h5.5V19a1.5 1.5 0 003 0v-5.5H19a1.5 1.5 0 000-3z"/>
</svg>
</div>
</label>
<!-- 10. Черточка / Линия -->
<label class="cursor-pointer group" title="Линия">
<input type="radio" name="shape" value="line" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<div class="w-5 h-1 rounded-full bg-current transition-colors"></div>
</div>
</label>
<!-- 11. Волна / Синусоида -->
<label class="cursor-pointer group" title="Волна">
<input type="radio" name="shape" value="wave" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-2 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<svg class="w-6 h-6 stroke-current transition-colors" fill="none" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24">
<path d="M2 12c2.5-4 5.5-4 8 0s5.5 4 8 0 3.5-4 4-4"/>
</svg>
</div>
</label>
<!-- 12. Двоичный блок 3x3 -->
<label class="cursor-pointer group" title="Матрица 3x3 (0/1)">
<input type="radio" name="shape" value="binary" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-1 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<div class="grid grid-cols-2 gap-0.5 font-mono text-[14px] leading-[12px] font-black transition-colors select-none">
<span>1</span><span>0</span>
<span>0</span><span>1</span>
</div>
</div>
</label>
</div>
<!-- Таб: Цвет -->
<div x-show="activeTab === 'color'" class="space-y-4">
<div class="flex items-center justify-between pb-1 border-b border-stone-100 dark:border-zinc-800">
<span class="text-xs font-bold text-stone-400 uppercase">Цветовая схема</span>
<span class="text-xs font-semibold px-2.5 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-400"
x-text="colors.length === 1 ? 'Монотон (1 цвет)' : (colors.length === 2 ? 'Дуотон (2 цвета)' : 'Триатон (3 цвета)')"></span>
</div>
<div class="space-y-3">
<template x-for="(color, index) in colors" :key="index">
<div class="flex items-center gap-2">
<div class="flex-1">
<div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400 uppercase"
x-text="colors.length === 1 ? 'Основной цвет' : ('Цвет ' + (index + 1))"></label>
<span class="text-xs font-mono uppercase text-stone-500 dark:text-stone-400" x-text="colors[index]"></span>
</div>
<input type="color" name="colors" x-model="colors[index]"
class="w-full h-10 border-none rounded-lg cursor-pointer bg-stone-50 dark:bg-zinc-800">
</div>
<template x-if="colors.length > 1">
<button type="button"
@click="colors.splice(index, 1); $nextTick(() => $el.closest('form').dispatchEvent(new Event('change')))"
class="self-end mb-1 p-2 text-stone-400 hover:text-red-500 rounded-lg hover:bg-stone-100 dark:hover:bg-zinc-800 transition-colors"
title="Удалить цвет">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</template>
</div>
</template>
</div>
<div x-show="colors.length < 3" class="pt-2">
<button type="button"
@click="colors.push(colors.length === 1 ? '#f97316' : '#06b6d4'); $nextTick(() => $el.closest('form').dispatchEvent(new Event('change')))"
class="w-full flex items-center justify-center gap-2 py-2.5 px-3 text-xs font-bold uppercase rounded-xl border-2 border-dashed border-stone-300 dark:border-zinc-700 text-stone-500 dark:text-stone-400 hover:border-emerald-500 hover:text-emerald-500 transition-colors cursor-pointer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
<span x-text="colors.length === 1 ? 'Добавить второй цвет (Дуотон)' : 'Добавить третий цвет (Триатон)'"></span>
</button>
</div>
</div>
<!-- Таб: Параметры -->
<div x-show="activeTab === 'params'" class="space-y-4">
{# ПЛОТНОСТЬ СЕТКИ (ЧИСЛО КОЛОНОК) #}<div>
<div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400 uppercase">Плотность галлюцинации</label>
<span class="text-xs transition-colors duration-300"
:class="cols >= 60 ? 'text-red-600' : (cols > 50 ? 'text-yellow-500' : 'text-emerald-600')"
x-text="cols"></span>
</div>
<input type="range" name="cols" min="10" max="80" x-model="cols"
@input="$el.dispatchEvent(new CustomEvent('input-change'))"
:class="cols >= 60 ? 'accent-red-600' : (cols > 50 ? 'accent-yellow-500' : 'accent-emerald-500')"
class="w-full transition-all duration-300">
</div>
{# РАЗМЕР ЭЛЕМЕНТА #}<div>
<div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400 uppercase">Размер гипно-точки</label>
<span class="text-xs transition-colors duration-300"
:class="radius < 4 ? 'text-red-600' : (radius < 5 ? 'text-yellow-600' : (radius > 15 ? 'text-red-600' : (radius > 12 ? 'text-yellow-600' : 'text-emerald-600')))"
x-text="radius"></span>
</div>
<input type="range" name="max_radius" min="3" max="20" x-model="radius"
@input="$el.dispatchEvent(new CustomEvent('input-change'))"
:class="radius < 4 ? 'accent-red-600' : (radius < 5 ? 'accent-yellow-500' : (radius > 15 ? 'accent-red-600' : (radius > 12 ? 'accent-yellow-500' : 'accent-emerald-500')))"
class="w-full accent-emerald-500">
</div>
{# МЕРЦАНИЕ #}<div>
<div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400 uppercase">Мерцание</label>
<span class="text-xs transition-colors duration-300"
:class="blink < 1 ? 'text-emerald-600' : (blink < 2 ? 'text-red-600' : (blink < 4 ? 'text-yellow-600' : 'text-emerald-600'))"
x-text="blink"></span>
</div>
<input type="range" name="blink" min="0" max="10" x-model="blink"
@input="$el.dispatchEvent(new CustomEvent('input-change'))"
:class="blink < 1 ? 'accent-emerald-600' : (blink < 2 ? 'accent-red-600' : (blink < 4 ? 'accent-yellow-500' : 'accent-emerald-500'))"
class="w-full accent-emerald-500">
</div>
{# ПОВОРОТЫ #}<div>
<div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400 uppercase">Укачивание</label>
<span class="text-xs transition-colors duration-300"
:class="rotation < -7 ? 'text-red-600' : (rotation < -4 ? 'text-yellow-600' : (rotation > 7 ? 'text-red-600' : (rotation > 4 ? 'text-yellow-600' : 'text-emerald-600')))"
x-text="rotation + '°'"></span>
</div>
<input type="range" name="rotation" min="-15" max="15" x-model="rotation"
@input="$el.dispatchEvent(new CustomEvent('input-change'))"
:class="rotation < -7 ? 'accent-red-600' : (rotation < -4 ? 'accent-yellow-500' : (rotation > 7 ? 'accent-red-600' : (rotation > 4 ? 'accent-yellow-500' : 'accent-emerald-500')))"
class="w-full accent-emerald-500">
</div>
{# МАСШТАБ #}<div>
<div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400">Взз-з-з-з…</label>
<span class="text-xs transition-colors duration-300"
:class="scale < 900 ? 'text-red-600' : (scale < 950 ? 'text-yellow-600' : (scale > 1030 ? 'text-red-600' : (scale > 1010 ? 'text-yellow-600' : 'text-emerald-600')))"
x-text="(scale / 10).toFixed(1) + '%'"></span>
</div>
<input type="range" name="scale" min="800" max="1040" x-model="scale"
@input="$el.dispatchEvent(new CustomEvent('input-change'))"
:class="scale < 900 ? 'accent-red-600' : (scale < 950 ? 'accent-yellow-500' : (scale > 1030 ? 'accent-red-600' : (scale > 1010 ? 'accent-yellow-500' : 'accent-emerald-500')))"
class="w-full accent-emerald-500">
</div>
{# НАКЛОН СЕТКИ #}<div>
<div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400 uppercase">Перекос</label>
<span class="text-xs transition-colors duration-300 text-emerald-600" x-text="angle + '°'"></span>
</div>
<input type="range" name="angle" min="-44" max="45" step="1" x-model="angle"
class="w-full accent-emerald-500">
</div>
</div>
</div>
<!-- Индикатор размышления / статус активности -->
<div class="h-8 mt-2 flex items-center justify-center">
<div x-show="isLoading"
x-transition
class="flex items-center gap-2 px-3.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400 text-xs font-bold font-mono tracking-wide animate-pulse">
<img src="{% static 'img/thinking.svg' %}" class="w-4 h-4 animate-spin [animation-duration:3s]">
<span>Глюкало думает...</span>
</div>
<div x-show="!isLoading && !hasChanges"
class="text-[11px] font-mono text-stone-400 dark:text-zinc-500 tracking-wider">
Параметры зафиксированы
</div>
<div x-show="!isLoading && hasChanges"
class="text-[11px] font-mono text-emerald-600 dark:text-emerald-500 font-semibold tracking-wider flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
<span>Готово к запуску транса</span>
</div>
</div>
<button type="submit"
:disabled="isLoading || !hasChanges"
:class="(!hasChanges || isLoading)
? 'bg-stone-200 dark:bg-zinc-800 text-stone-400 dark:text-zinc-600 cursor-not-allowed shadow-none'
: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-900/20 active:scale-95 cursor-pointer'"
class="w-full mt-2 font-bold py-3.5 px-6 rounded-xl transition-all flex items-center justify-center gap-2">
<template x-if="isLoading">
<span class="flex items-center gap-2">
<img src="{% static 'img/thinking.svg' %}" class="w-5 h-5 animate-spin [animation-duration:2s]">
<span>ГЛЮКОТРОН В ТРАНСЕ...</span>
</span>
</template>
<template x-if="!isLoading">
<span>ЗАПУСТИТЬ ГЛЮКОТРОН</span>
</template>
</button>
</form>
</aside>
<!-- ПРАВАЯ ПАНЕЛЬ: ПРЕВЬЮ -->
<section class="flex flex-col gap-6">
<div id="preview"
class="w-full h-[744px] bg-stone-200 dark:bg-zinc-900 rounded-3xl flex items-center justify-center border border-stone-300 dark:border-zinc-800 overflow-hidden relative group">
<div class="text-stone-400 flex flex-col items-center gap-4 text-center px-6">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"
:class="isLoading ? 'animate-pulse text-emerald-500 opacity-80' : 'opacity-50'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="transition-all duration-300"
:class="isLoading ? 'text-emerald-600 dark:text-emerald-400 font-mono text-xs font-bold uppercase tracking-wider animate-pulse' : 'font-medium opacity-50'"
x-text="isLoading ? 'Гипножаба переваривает пиксели…' : 'Ваше искусство появится здесь'"></p>
</div>
<!-- Заглушка для кнопок действий (появятся при генерации) -->
<div
class="absolute bottom-6 right-6 flex items-center gap-3 translate-y-20 group-hover:translate-y-0 transition-transform duration-300 pointer-events-none">
<button id="like-to-gallery" type="button" title="Нравится!"
class="pointer-events-auto flex items-center gap-2 bg-emerald-600/85 hover:bg-emerald-600 text-white text-xs font-bold uppercase tracking-wide backdrop-blur-md px-4 py-2.5 rounded-full shadow-xl transition-all hover:scale-105 active:scale-95 cursor-pointer">
<span>Кандидат в галерею транса</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
<button id="dislike-from-gallery" type="button" title="Не нравится!"
class="pointer-events-auto flex items-center gap-2 bg-rose-900/90 hover:bg-rose-500 text-white text-xs font-bold uppercase tracking-wide backdrop-blur-md px-4 py-2.5 rounded-full shadow-xl transition-all hover:scale-105 active:scale-95 cursor-pointer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
<span>Гипнoжаба такое не одобрит</span>
</svg>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<button
type="button"
@click="copySvg()"
class="bg-stone-800 text-white dark:bg-zinc-800 py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-stone-700 transition-colors cursor-pointer active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
<span x-text="copied ? 'СКОПИРОВАНО В БУФЕР!' : 'КОПИРОВАТЬ SVG'"></span>
</button>
<button
type="button"
@click="downloadSvg()"
class="bg-emerald-600 text-white py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-emerald-500 transition-colors shadow-lg shadow-emerald-900/20 cursor-pointer active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
СКАЧАТЬ
</button>
</div>
</section>
</div>
<!-- СЕКЦИЯ: ТРЕХЭТАЖНАЯ ГАЛЕРЕЯ -->
<section class="mt-20 space-y-12">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-stone-200 dark:border-zinc-800">
<div>
<h2 class="text-3xl font-black uppercase tracking-tighter">Галерея транса</h2>
<p class="text-sm text-stone-500 dark:text-zinc-400 mt-1">Многоуровневое хранилище зрительного подчинения</p>
</div>
<a href="#" class="text-emerald-600 dark:text-emerald-400 font-bold hover:underline self-start sm:self-auto">Смотреть весь архив →</a>
</div>
<!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадет только что созданный шедевр (нужно чтобы это было интерактивно... лаккнул и сразу через HTMX на первом уровне галереи -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full bg-amber-500"></div>
<div>
<h3 class="text-xl font-black uppercase tracking-tight flex items-center gap-2">
Плеск бессознательного
<span class="text-xs font-bold font-mono px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-950/70 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-800/50">FRESH STREAM</span>
</h3>
<p class="text-xs text-stone-400 dark:text-zinc-500">Свежие галлюцинации из инкубатора • Первичная оценка сообщества</p>
</div>
</div>
<span class="text-xs font-semibold text-stone-400">Свежее • до 100 лайков</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<template x-for="i in 6">
<div class="aspect-square bg-stone-200 dark:bg-zinc-900 rounded-xl border border-stone-300 dark:border-zinc-800 overflow-hidden relative group cursor-pointer hover:border-amber-500/50 transition-all hover:scale-[1.02]">
<div class="absolute inset-0 flex items-center justify-center text-stone-400 group-hover:scale-110 transition-transform">
<svg class="w-8 h-8 opacity-20" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
</svg>
</div>
<div class="absolute bottom-2 left-2 flex items-center gap-1 text-[11px] font-bold text-amber-600 dark:text-amber-400 bg-white/90 dark:bg-zinc-900/90 px-2 py-0.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<span x-text="Math.floor(Math.random()*99) + 1"></span>
</div>
</div>
</template>
</div>
</div>
<!-- 2-й ЭТАЖ: ОДОБРЕНО МОЗГОВЫМ СЛИЗНЕМ (Кураторский отбор) -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full bg-cyan-500"></div>
<div>
<h3 class="text-xl font-black uppercase tracking-tight flex items-center gap-2">
Одобрено Мозговым Слизнем
<span class="text-xs font-bold font-mono px-2 py-0.5 rounded-full bg-cyan-100 dark:bg-cyan-950/70 text-cyan-700 dark:text-cyan-400 border border-cyan-300 dark:border-cyan-800/50">CURATED</span>
</h3>
<p class="text-xs text-stone-400 dark:text-zinc-500">Проверено контролем качества • Высокий психоделический резонанс</p>
</div>
</div>
<span class="text-xs font-semibold text-stone-400">100999 лайков</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<template x-for="i in 6">
<div class="aspect-square bg-stone-200 dark:bg-zinc-900 rounded-xl border border-stone-300 dark:border-zinc-800 overflow-hidden relative group cursor-pointer hover:border-cyan-500/50 transition-all hover:scale-[1.02]">
<div class="absolute inset-0 flex items-center justify-center text-stone-400 group-hover:scale-110 transition-transform">
<svg class="w-8 h-8 opacity-20" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
</svg>
</div>
<div class="absolute bottom-2 left-2 flex items-center gap-1 text-[11px] font-bold text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-zinc-900/90 px-2 py-0.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<span x-text="100 + Math.floor(Math.random()*400)"></span>
</div>
</div>
</template>
</div>
</div>
<!-- 3-й ЭТАЖ: ГЛУБОКИЙ ТРАНС (Высшая лига) -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-5 h-5"><img src="{% static 'img/favicon.svg' %}" alt="Favicon" class="w-full h-full object-cover"></div>
<div>
<h3 class="text-xl font-black uppercase tracking-tight flex items-center gap-2">
Глубокий транс
<span class="text-xs font-bold font-mono px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950/70 text-emerald-700 dark:text-emerald-400 border border-emerald-300 dark:border-emerald-800/50">TOP TIER</span>
</h3>
<p class="text-xs text-stone-400 dark:text-zinc-500">Золотой фонд гипноза • Абсолютное подчинение воли</p>
</div>
</div>
<span class="text-xs font-semibold text-stone-400">1000+ лайков</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<template x-for="i in 6">
<div class="aspect-square bg-stone-200 dark:bg-zinc-900 rounded-2xl border-2 border-emerald-500/30 dark:border-emerald-500/20 overflow-hidden relative group cursor-pointer shadow-lg shadow-emerald-950/5 hover:border-emerald-500 transition-all hover:scale-[1.02]">
<div class="absolute inset-0 flex items-center justify-center text-emerald-600/40 group-hover:scale-110 transition-transform">
<svg class="w-10 h-10 opacity-30" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
</svg>
</div>
<div class="absolute top-2 right-2 text-[10px] font-black tracking-widest text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 backdrop-blur-md px-1.5 py-0.5 rounded border border-emerald-500/20">
★ 99%
</div>
<div class="absolute bottom-2.5 left-2.5 flex items-center gap-1.5 text-xs font-bold text-white bg-emerald-600/90 backdrop-blur-md px-2.5 py-1 rounded-full shadow">
<span></span> <span x-text="500 + Math.floor(Math.random()*500)"></span>
</div>
</div>
</template>
</div>
</div>
</section>{% endblock CONTENT %}
Generated
+401 -12
View File
@@ -1,10 +1,9 @@
# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand.
[[package]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.12.1" version = "3.12.1"
description = "ASGI specs, helper code, and adapters" description = "ASGI specs, helper code, and adapters"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -16,11 +15,32 @@ files = [
mypy = ["mypy (>=1.14.0)"] mypy = ["mypy (>=1.14.0)"]
tests = ["pytest", "pytest-asyncio"] tests = ["pytest", "pytest-asyncio"]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"},
{file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"},
]
[package.dependencies]
soupsieve = ">=1.6.1"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]] [[package]]
name = "django" name = "django"
version = "6.1" version = "6.1"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false optional = false
python-versions = ">=3.12" python-versions = ">=3.12"
files = [ files = [
@@ -41,7 +61,6 @@ bcrypt = ["bcrypt (>=4.1.1)"]
name = "django-debug-toolbar" name = "django-debug-toolbar"
version = "7.0.0" version = "7.0.0"
description = "A configurable set of panels that display various debug information about the current request/response." description = "A configurable set of panels that display various debug information about the current request/response."
category = "dev"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -57,7 +76,6 @@ sqlparse = ">=0.2"
name = "django-environ" name = "django-environ"
version = "0.14.0" version = "0.14.0"
description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application."
category = "main"
optional = false optional = false
python-versions = "<4,>=3.9" python-versions = "<4,>=3.9"
files = [ files = [
@@ -70,11 +88,26 @@ develop = ["coverage[toml] (>=5.0a4)", "furo (>=2024.8.6)", "pytest (>=4.6.11)",
docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-copybutton", "sphinx-notfound-page"] docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-copybutton", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"] testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"]
[[package]]
name = "etpgrf"
version = "0.1.6.post1"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
optional = false
python-versions = ">=3.10"
files = [
{file = "etpgrf-0.1.6.post1-py3-none-any.whl", hash = "sha256:0863b14385bdacdd405f137ca2ce6bdb6f683f0189e8c927196a1eee754366be"},
{file = "etpgrf-0.1.6.post1.tar.gz", hash = "sha256:984d201cff232a58c05b6f4455a50f822162520df829ad4d543bfe0b7fd962a9"},
]
[package.dependencies]
beautifulsoup4 = ">=4.10.0"
lxml = ">=4.9.0"
regex = ">=2022.1.18"
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "26.0.0" version = "26.0.0"
description = "WSGI HTTP Server for UNIX" description = "WSGI HTTP Server for UNIX"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -93,11 +126,215 @@ setproctitle = ["setproctitle"]
testing = ["coverage", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"] testing = ["coverage", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"]
tornado = ["tornado (>=6.5.0)"] tornado = ["tornado (>=6.5.0)"]
[[package]]
name = "hashids"
version = "1.3.1"
description = "Implements the hashids algorithm in python. For more information, visit http://hashids.org/"
optional = false
python-versions = ">=2.7"
files = [
{file = "hashids-1.3.1-py2.py3-none-any.whl", hash = "sha256:8bddd1acba501bfc9306e7e5a99a1667f4f2cacdc20cbd70bcc5ddfa5147c94c"},
{file = "hashids-1.3.1.tar.gz", hash = "sha256:6c3dc775e65efc2ce2c157a65acb776d634cb814598f406469abef00ae3f635c"},
]
[package.extras]
test = ["pytest (>=2.1.0)"]
[[package]]
name = "lxml"
version = "6.1.2"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
optional = false
python-versions = ">=3.8"
files = [
{file = "lxml-6.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:522387e05cd015a81d1dc621fb167fb42b8f629ccd2e8b39de583828f165aae6"},
{file = "lxml-6.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d86130d70a2557cdf825dffc56255f1f16b83a7bbeab677b4cd040c4c53d8c52"},
{file = "lxml-6.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08cd52e6487435c75f2da0a5b276beef7fed161681b93ab766e66b954f0c349a"},
{file = "lxml-6.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:785761d5123f222cd97f2263a510107226fe32ce7aa7824a90616a41c574ace1"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae520f189895c5dd7eeb2b7a372d464da6f4a1ba1d0ecb741b1d4fe4c1f699ac"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83e7510a6dda8df41d1b68b783de2953b3feb55a11dcebf693201ebaa5cc0c4a"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:c20fa05d128c463209ef5323ebf33ee1cac6d87cdc3933fd789fd3c101017c8e"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:e7269cc410f3cdf84a66914fc0ef54b1618115c87fb4f9a59a05c5dfc23bece1"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7233a987a101bdf79059014130262a01339094a0a709f175162542f33b55d4e"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee23f6599682bd4d48bb757c0633e78774eedfb65a7e52851f9ad182eeeb625e"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e062f5ac1255dfa6c98e3e3863ec18bc79d0947d22d08921a3ca60cee40559fd"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:cb0cf498efa3204621b3c5576f0accd80ad2ee85575f1cae5d2f98de32c8d9cc"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ee7410c98222070fd717ad881ee2a80cc11826b7001b9a5a807155d8918bfc7a"},
{file = "lxml-6.1.2-cp310-cp310-win32.whl", hash = "sha256:aa224ecc613d411690aa650dbf01daafbe385cd6c67145e80bc5fc01b3a71469"},
{file = "lxml-6.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1c0173595dc1c25768f42681a1517dcfc74bb18a34695f127931cbd05f4dead6"},
{file = "lxml-6.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:adbecbfe44a497c742792457b1c27300617967c18c3934d2416023eba8d8c553"},
{file = "lxml-6.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da6a4f55f0e3308c07354b1ee239c5550afc212f81629a6067db505ace3b667a"},
{file = "lxml-6.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f4d2c36fd5997d30ff19c29fb93293401d0daaf87512297d47610e6883964b5"},
{file = "lxml-6.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1d55a614d2f0457b1f7511c1b7bec0db0dcdd4af4d09d226829eb054c647527c"},
{file = "lxml-6.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:575fef7f30048b744dffb3e4ff64a18cac7dba3fd26efdea5730ade9d1bdeb33"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79b428c3242e63bdacf3b526a34e0b8b26583846fc597da84b8f0c3d5ea446b2"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12ecfea07d767f6accbf30b014e1c477b5eabb13eb4e8c748215efb52c0e314a"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:bfcbee8ffff4188f4c6d97eceeff36d8eb983cf838933cbc12ce5f5dd51476c6"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:822d9397033edbe530a13bb1e0091c0e817536b6aba87a9b4ad626ed779ca0bd"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4303f904fb6c41b58dc70743b1d8a470aba6c9897427c48324cff1a95673ddb4"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdd35422de747237f451e821766e2b6be3dd2c31955c1ecd7f17984c5b9bb62d"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b3ca02ef3b5920b88119c82eb6badfb2d082b1f681d528a856dcce17c8706da8"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4bf14db2f0214003ec7f46c4300e2065668fc93e20448c1c95bac2e952072168"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2afd1688e372d8eafaa6f56c589399e0a87d086a0c110f6346b0b50f42e67e25"},
{file = "lxml-6.1.2-cp311-cp311-win32.whl", hash = "sha256:aea814342f6afd20d832937ff8b333cd6506428a39c0c4c70c2380aab1887bfb"},
{file = "lxml-6.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:b3db5497af55f7a557c95265dd3b91c75dc56364a7b59f258c45fa5576dce058"},
{file = "lxml-6.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:e8dc3d29f2ed2bbf24c205a86326d6681230ace55abfb3f9d5230f42078ad63d"},
{file = "lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237"},
{file = "lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3"},
{file = "lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40"},
{file = "lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313"},
{file = "lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3"},
{file = "lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f"},
{file = "lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49"},
{file = "lxml-6.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4"},
{file = "lxml-6.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0"},
{file = "lxml-6.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c"},
{file = "lxml-6.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881"},
{file = "lxml-6.1.2-cp313-cp313-win32.whl", hash = "sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c"},
{file = "lxml-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5"},
{file = "lxml-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73"},
{file = "lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29"},
{file = "lxml-6.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b"},
{file = "lxml-6.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324"},
{file = "lxml-6.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b"},
{file = "lxml-6.1.2-cp314-cp314-win32.whl", hash = "sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605"},
{file = "lxml-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d"},
{file = "lxml-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63"},
{file = "lxml-6.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222"},
{file = "lxml-6.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45"},
{file = "lxml-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b"},
{file = "lxml-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c"},
{file = "lxml-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d"},
{file = "lxml-6.1.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e"},
{file = "lxml-6.1.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060"},
{file = "lxml-6.1.2-cp315-cp315-win32.whl", hash = "sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f"},
{file = "lxml-6.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b"},
{file = "lxml-6.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa"},
{file = "lxml-6.1.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86"},
{file = "lxml-6.1.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088"},
{file = "lxml-6.1.2-cp315-cp315t-win32.whl", hash = "sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6"},
{file = "lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0"},
{file = "lxml-6.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b"},
{file = "lxml-6.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aebcc6b184c935e1f7091c09124cfe5107b7c2253894ba23ad646828c17e4c3b"},
{file = "lxml-6.1.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6330cf0ce83f6273ad8ad99bdd25d6ebb3863912f9ac717f96bc8942706e0e26"},
{file = "lxml-6.1.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af6585a466cee2c5a524f7fffc591844bd604a29fdd9cade964f548512b5ef7e"},
{file = "lxml-6.1.2-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:841630176c15fa5d3c5cd6f755435d3c5540a82e1dd2a7de1799401f92ee6d24"},
{file = "lxml-6.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:215bb3cc4be015ccac3c7d4f25eb7b941f857fe5b02c0e3504cca61f7fb12455"},
{file = "lxml-6.1.2-cp38-cp38-win32.whl", hash = "sha256:7c687fd8e558c7d169f6f1987b696f37824d3a097f291bffd0ab4a2ea2307dfb"},
{file = "lxml-6.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:69df1856cb6c065e5bfd23adcc7408bfa6dcf32b0018373a99b0769bd86e2256"},
{file = "lxml-6.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2dcc69e307e0916c7a0b552212010938d02a664d29b6bda75ab2bc5fa487c861"},
{file = "lxml-6.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:243ecef7cb7415766dd742336cd5b8361a84c6f297e2773c865b783724cbbe74"},
{file = "lxml-6.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08f0c9ed7cded07c5e798b17c9c25bbba5d0650c8ff0a7f65f84c634966f0f10"},
{file = "lxml-6.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8ffb17ec0a8bae18b6628ae40b0896eb264dd285e39a0faa864965c00933b64c"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d78ba560f3dd404d87b1fcc89b2b382d638ea2998431a3b2e5cda0f3ba2da91"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2b7fe53abced1fe8bd984a9ab3c8c98bc093ec4f9f543089a8817a493818208"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:20134744db7abcbd5232214e767814ef64e5ab57a5b7df93a2bd68b74ef0a6c0"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:a02164a8cd3e2dc028918e51af844c934c7a24a0b8f4064368360aa14ad1aac4"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec8d09f460fdeb65f9ead9b75941e312def4bcbb23e1f951b7def061eb99501d"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1fcfe8481302e6dec07909914b8f3f9e1739ae1615209d4b9e7544325fb699c4"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3be94d2464f19e42d8c39a299f356b12f2fd095c28793671eabfcd9db9c76987"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:86d93dc3882c283e9aa2124d7d2b50c85579485216a2b3b7f91ba479e31a128f"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e613018a5ac66de7abaf1acaae0d7af37a5e1b9bf1ae190a1198b0fdb988ad8"},
{file = "lxml-6.1.2-cp39-cp39-win32.whl", hash = "sha256:446f1f92c137e0cbb97eb7e932e15315c11a7c86974f43f15e68c9707ac6a9f6"},
{file = "lxml-6.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:058c79e172926ef524fb3c7c6beea4b55e15886ac99cb0c139ecaac6b375f1e2"},
{file = "lxml-6.1.2-cp39-cp39-win_arm64.whl", hash = "sha256:5295205fd57510c19a0e46385b516119f3a781d45c2672159bce02949238981a"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:feda2ef68c339987dfb370af3a4b785dbc40f925723fe2365e68e43c2640f85a"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9bdc2db9e04538f917bba0242920764dd740649d8df58700d6d687ead4429429"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a16457e330b7099aa5a8e8bfa5d53a33a1672a819fa656157e9e6dc433ac7a4"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:614d4c5a34556e369b86cfcc8d0cf71cd0759a3444a464a07a9427ab0f5e3a99"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18467b0e9f7f0bc477df69e99829a59ae17fb37d34e5f68399371c7c67be9002"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:351855814dec4ad55ca5f24d0f4b1cdaca7927fe48023a2965351845f3b60cff"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4622c5616683faf63791b349e6c8dad7717412dc5f29f4febe7575f110609a86"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:733dfb492ec3dfef8350a5cc896e90d202c5171e791e1609e77563751d69a15d"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4618b20f43dc98b49569b1dc822176140ea0f2598d672a6989187ba49bcbfec1"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc5e25992f5545709000d840c6cafdbd022781a7a0ed79d58a5633733a4e8"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:662432a6103e671d971e06e75ed146d9ff67f39d2c98c2f26613b6057f54eafc"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ba0dfead73be5be9ad0b7fbf9f31ff29c1b1eae858816dfc8d85099d6e4af0d6"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:85690cfc8ed54c4292e36a08bcf984dde7957e653fd6d94f59184244bcc35843"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e92e4419cad18d60b14bf18b82152fbae67f4b1128be7d73b172df275554f5d9"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50ee0c360862f4152db835b456e38614f94b674bca2a47bc8de7171ee6ccbbb8"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:927f3e1d04dc0906265fc0416c13500363e42cd683bbb8d46911c79b73d26800"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f86e23ed610727a7f025ebbff788f22a7956d3f1b24a25bb1d9286fc7b7642b0"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2374235206ec83d4827ad219c93c0f7366b93626eab85392c0ee7c8026649376"},
{file = "lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18"},
]
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html-clean = ["lxml_html_clean"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "main"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
@@ -109,7 +346,6 @@ files = [
name = "pillow" name = "pillow"
version = "12.3.0" version = "12.3.0"
description = "Python Imaging Library (fork)" description = "Python Imaging Library (fork)"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -210,11 +446,155 @@ test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"] xmp = ["defusedxml"]
[[package]]
name = "pytils"
version = "0.4.4"
description = "Russian-specific string utils"
optional = false
python-versions = "*"
files = [
{file = "pytils-0.4.4-py3-none-any.whl", hash = "sha256:e54c16465a5fdb65d414e2da8045e6cc6de79889acda6143dcef2e1e86a1a840"},
{file = "pytils-0.4.4.tar.gz", hash = "sha256:9992a96caad57daa211584df1da4fd825f11e836d3ed93011785f1d02ab6f0ca"},
]
[[package]]
name = "regex"
version = "2026.7.19"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.10"
files = [
{file = "regex-2026.7.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b"},
{file = "regex-2026.7.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52"},
{file = "regex-2026.7.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6"},
{file = "regex-2026.7.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902"},
{file = "regex-2026.7.19-cp310-cp310-win32.whl", hash = "sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e"},
{file = "regex-2026.7.19-cp310-cp310-win_amd64.whl", hash = "sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db"},
{file = "regex-2026.7.19-cp310-cp310-win_arm64.whl", hash = "sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6"},
{file = "regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c"},
{file = "regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae"},
{file = "regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc"},
{file = "regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327"},
{file = "regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d"},
{file = "regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965"},
{file = "regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e"},
{file = "regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d"},
{file = "regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd"},
{file = "regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68"},
{file = "regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a"},
{file = "regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5"},
{file = "regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312"},
{file = "regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d"},
{file = "regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40"},
{file = "regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38"},
{file = "regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15"},
{file = "regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2"},
{file = "regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404"},
{file = "regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e"},
{file = "regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8"},
{file = "regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2"},
{file = "regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda"},
{file = "regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0"},
{file = "regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4"},
{file = "regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974"},
{file = "regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d"},
{file = "regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd"},
{file = "regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac"},
{file = "regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a"},
{file = "regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4"},
{file = "regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa"},
{file = "regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac"},
{file = "regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459"},
{file = "regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3"},
{file = "regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518"},
{file = "regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44"},
{file = "regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78"},
{file = "regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2"},
{file = "regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547"},
{file = "regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5"},
]
[[package]]
name = "soupsieve"
version = "2.9.2"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.10"
files = [
{file = "soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823"},
{file = "soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74"},
]
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.5" version = "0.5.5"
description = "A non-validating SQL parser." description = "A non-validating SQL parser."
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -226,11 +606,21 @@ files = [
dev = ["build"] dev = ["build"]
doc = ["sphinx"] doc = ["sphinx"]
[[package]]
name = "typing-extensions"
version = "4.16.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
files = [
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
]
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.3" version = "2026.3"
description = "Provider of IANA time zone data" description = "Provider of IANA time zone data"
category = "main"
optional = false optional = false
python-versions = ">=2" python-versions = ">=2"
files = [ files = [
@@ -242,7 +632,6 @@ files = [
name = "whitenoise" name = "whitenoise"
version = "6.12.0" version = "6.12.0"
description = "Radically simplified static file serving for WSGI applications" description = "Radically simplified static file serving for WSGI applications"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -256,4 +645,4 @@ brotli = ["brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.14" python-versions = "^3.14"
content-hash = "b9c94317c0624ff0156fd9db6187c2a709b4c0ad5cce5424a745a5091e3816ad" content-hash = "61448c711939177b8920ed0842c4f9735273055dd3f61aa877b196dc17922631"
@@ -0,0 +1,77 @@
/**
* CodeMirror 6 - Height Control Patch
*
* Этот патч перехватывает создание CodeMirror редакторов и копирует
* атрибут data-height с textarea на создаваемый wrapper div.
*
* Использование в admin.py:
* textarea widget: attrs={'data-height': '50px', 'data-codemirror-editor': '1'}
*
* Загружается ПОСЛЕ editor.js через Media в Admin класс.
*/
(function () {
'use strict';
/**
* Применяем высоту к уже созданным wrapper'ам
* (CodeMirror уже загрузился и создал обёртки)
*/
function applyHeightsToExistingWrappers() {
// Находим все textarea'ы с data-codemirror-editor
document.querySelectorAll('textarea[data-codemirror-editor]').forEach((textarea) => {
// Соседний div (wrapper, созданный CodeMirror)
const wrapper = textarea.previousElementSibling;
// Проверяем что это действительно wrapper CodeMirror
if (wrapper && wrapper.classList && wrapper.classList.contains('cm6-editor-wrapper')) {
// Копируем data-height на style если указано
if (textarea.dataset.height) {
wrapper.style.height = textarea.dataset.height;
}
// Копируем data-width на style если указано (для ширины)
if (textarea.dataset.width) {
wrapper.style.width = textarea.dataset.width;
wrapper.style.minWidth = textarea.dataset.width;
// Также устанавливаем ширину на сам .cm-editor внутри wrapper
// Используем !important чтобы переопределить flex: 1 1 auto из CSS
const cmEditor = wrapper.querySelector('.cm-editor');
// if (cmEditor) {
// cmEditor.style.cssText = `width: ${textarea.dataset.width} !important; flex: 0 0 auto;`;
// }
}
// Копируем классы с textarea если они есть
// (например cm6-editor-wrapper-xs, cm6-editor-wrapper-sm и т.д.)
if (textarea.className) {
// Добавляем классы из textarea (сохраняя cm6-editor-wrapper)
textarea.className.split(' ').forEach((cls) => {
if (cls && cls !== 'cm6-editor-wrapper') {
wrapper.classList.add(cls);
}
});
}
}
});
}
// Применяем высоты когда DOM готов
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyHeightsToExistingWrappers);
} else {
// DOM уже загружен
applyHeightsToExistingWrappers();
}
// Также применяем для динамически добавленных элементов (если загружают CodeMirror после инициализации)
const observer = new MutationObserver(() => {
applyHeightsToExistingWrappers();
});
observer.observe(document, {
childList: true,
subtree: true,
});
})();
@@ -0,0 +1,78 @@
/* CodeMirror 6 - Стили для правильного отображения */
:root {
color-scheme: light dark;
--brdr: light-dark(#333, #ccc);
--accent-mode-color: light-dark(#222, #eee);
}
.cm6-editor-wrapper {
/* Основной wrapper, созданный CodeMirror */
flex-direction: column;
overflow: hidden;
}
.cm-editor {
/* Сам редактор внутри wrapper - растягиваем на всю доступную высоту */
flex: 1 1 auto;
font-family: monospace;
line-height: 1;
border: 1px solid var(--brdr);;
}
/****************************
Утилитарные классы для управления шириной.
Мы ищем редактор .cm-editor ВНУТРИ элемента с нашим классом.
****************************/
.codemirror-width-s > .cm-editor {
/* Маленький (для чисел, коротких ID) */
max-width: 8em !important;
}
.codemirror-width-m > .cm-editor {
/* Средний (для URL, коротких строк) */
max-width: calc(50% - 13em) !important; /* Вычитание 13em подобрано. Это ширина бокового меню слева */
}
.codemirror-width-l > .cm-editor {
/* Большой (для текста, JSON, HTML) */
max-width: calc(75% - 13em) !important;
}
.codemirror-width-xl > .cm-editor {
/* Во всю ширину контейнера */
max-width: calc(100% - 13em) !important;
}
/* Ширина редактора зависит от видимости бокового меню Django Admin.
На экранах < 767px меню прячется, поэтому вычитание 13em неактуально.
Подобрано значение 3ex. */
@media (max-width: 767px) {
.codemirror-width-m > .cm-editor,
.codemirror-width-l > .cm-editor,
.codemirror-width-xl > .cm-editor {
max-width: calc(100% - 3ex) !important;
}
}
/* --- Новый класс для минимальной высоты --- */
.codemirror-min-height-2 .cm-editor {
min-height: 3.5em; /* 5 строк */
}
.codemirror-min-height-5 .cm-editor {
min-height: 8em; /* 5 строк */
}
.codemirror-min-height-10 .cm-editor {
min-height: 16em; /* 10 строк */
}
.codemirror-no-lines .cm-gutters {
/* Скрываем нумерацию строк */
display: none !important;
}
/* Скрыть оригинальный textarea */
textarea[data-codemirror-editor] {
display: none !important;
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+1 -1
View File
@@ -1 +1 @@
<svg width="48" height="48" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg"><style>.hypn0{transform-origin:center;animation: spin 5s linear infinite,colorShift 7s infinite}@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(-360deg);}}@keyframes colorShift{0%{fill:#e12}20%{fill:#f70}40%{fill:#1cf}60%{fill:#91f}80%{fill:#1f6}100%{fill:#e12}}</style><path class="hypn0" fill="#e12" d="m8,5c-3,4-4,5-5,8-10,32 33,44 42,19 7,-28-26,-41-35,-16-2,8 1,17 10,20 7,2 15,-1 17,-8 2,-6-1,-13-8,-15-9,-2-16,11-6,16 4,0 3,-3 1,-5-3,-3 1,-7 4,-6 3,1 5,5 4,8-1,4-6,7-10,5-5,-2-8,-8-7,-14 2,-7 10,-10 16,-8 20,12 8,36-13,29-10,-4-15,-16-11,-26 1,-3 2,-3 4,-7 1,-3-1,-2-3,0z"/></svg> <svg width="48" height="48" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg"><style>.h{transform-origin:center;animation:spin 5s linear infinite,colorShift 7s infinite}@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(-360deg);}}@keyframes colorShift{0%{fill:#e13}20%{fill:#f70}40%{fill:#1cf}60%{fill:#91f}80%{fill:#1f6}100%{fill:#e13}}</style><path class="h" fill="#e13" d="m8,5c-3,4-4,5-5,8-10,32 33,44 42,19 7,-28-26,-41-35,-16-2,8 1,17 10,20 7,2 15,-1 17,-8 2,-6-1,-13-8,-15-9,-2-16,11-6,16 4,0 3,-3 1,-5-3,-3 1,-7 4,-6 3,1 5,5 4,8-1,4-6,7-10,5-5,-2-8,-8-7,-14 2,-7 10,-10 16,-8 20,12 8,36-13,29-10,-4-15,-16-11,-26 1,-3 2,-3 4,-7 1,-3-1,-2-3,0z"/></svg>

Before

Width:  |  Height:  |  Size: 701 B

After

Width:  |  Height:  |  Size: 692 B

+1 -1
View File
@@ -1 +1 @@
<svg width="156" height="46" viewBox="0 0 156 46" version="1.1" xmlns="http://www.w3.org/2000/svg"><g style="fill:#888;"><path d="m 0,2 h 9 v 11 0 c 5,-7 15,-1 15,3 V 33 H 18 V 20 C 18,16 12,14 9,19 V 33 H 3 V 7 H 0 Z" /><path d="m 40,27 h 2 l 4,-16 h 6 c -3,10 -6,21 -10,29 -3,4 -9,5 -13,1 l 3,-4 c 3,2 5,0 7,-3 H 36 L 28,11 h 6 z" /><path d="m 53,11 h 8 c 0,0 1,2 1,3 21,-14 18,29 1,20 v 9 h -6 V 16 h -3 z m 10,8 -0,9 c 13,9 9,-20 0,-9 z" /><path d="m 80,11 h 8 c 0,1 1,2 1,3 3,-3 15,-5 15,5 V 33 H 98 V 21 c 0,-6 -7,-5 -9,-2 V 33 h -6 V 16 h -3 z" /></g><path style="fill:#c22" d="m 114,4 c -3,4 -4,5 -5,8 -10,32 33,44 42,19 7,-28 -26,-41 -35,-16 -2,8 1,17 10,20 7,2 15,-1 17,-8 2,-6 -1,-13 -8,-15 -9,-2 -16,11 -6,16 4,0 3,-3 1,-5 -3,-3 1,-7 4,-6 3,1 5,5 4,8 -1,4 -6,7 -10,5 -5,-2 -8,-8 -7,-14 2,-7 10,-10 16,-8 20,12 8,36 -13,29 -10,-4 -15,-16 -11,-26 1,-3 2,-3 4,-7 1,-3 -1,-2 -3,0 z" /></svg> <svg width="160" height="48" viewBox="0 0 160 48" version="1.1" xmlns="http://www.w3.org/2000/svg"><style>.l{fill:#888;transition:fill 0.3s ease;}@media(prefers-color-scheme:light){.l{fill: #333;}}.h{transform-box: fill-box;transform-origin:center;animation:spin 5s linear infinite,colorShift 7s infinite}@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(-360deg);}}@keyframes colorShift{0%{fill:#e13}20%{fill:#f70}40%{fill:#1cf}60%{fill:#91f}80%{fill:#1f6}100%{fill:#e13}}</style><g class="l"><path d="m1,3h 9v11 0c5,-7 15,-1 15,3V33H18V20C18,16 12,14 9,19 V33H3V7H0Z"/><path d="m41,28h2l4,-16h6c-3,10-6,21-10,29-3,4-9,5-13,1l3,-4c3,2 5,0 7,-3H36L28,11h6z"/><path d="m54,12h8c0,0 1,2 1,3 21,-14 18,29 1,20v9h-6V16h-3zm10,8-0,9c13,9 9,-20 0,-9z"/><path d="m81,12h8c0,1 1,2 1,3 3,-3 15,-5 15,5V33H98V21c0,-6-7,-5-9,-2V33h-6V16h-3z"/></g><path class="h" fill="#e13" d="m116,5c-3,4-4,5-5,8-10,32 33,44 42,19 7,-28-26,-41-35,-16-2,8 1,17 10,20 7,2 15,-1 17,-8 2,-6 -1,-13-8,-15-9,-2-16,11-6,16 4,0 3,-3 1,-5-3,-3 1,-7 4,-6 3,1 5,5 4,8-1,4-6,7-10,5-5,-2-8,-8-7,-14 2,-7 10,-10 16,-8 20,12 8,36-13,29-10,-4-15,-16-11,-26 1,-3 2,-3 4,-7 1,-3-1,-2-3,0z"/></svg>

Before

Width:  |  Height:  |  Size: 899 B

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 51 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "hypn0",
"short_name": "hypn0",
"icons": [
{
"src": "/static/img/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/img/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#000000" height="800" width="800" viewBox="0 0 512 512" ><path d="M477 174c8.812-13.708 13.948-29.993 13.948-47.466 0-48.55-39.499-88.048-88.05-88.048-32.839 0-61.516 18.084-76.651 44.803-22.773-5.031-46.356-7.586-70.424-7.586-24.003 0-47.624 2.55-70.427 7.582-15.135-26.717-43.813-44.799-76.65-44.799-48.55 0-88.048 39.498-88.048 88.049 0 17.473 5.137 33.759 13.949 47.467C11.987 204.676 0 239.016 0 274.61c0 53.849 27.194 104.194 76.573 141.763C124.677 452.97 188.399 473.125 256 473.125s131.323-20.155 179.427-56.753C484.806 378.803 512 328.457 512 274.609c0-35.59-11.986-69.935-34.822-100.219zM403.076 70.224c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.436-56.701 56.701-56.701zm-294.152 0c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.435-56.701 56.701-56.701zM56.629 197.71a87.57 87.57 0 0 0 52.294 17.264c48.55 0 88.05-39.498 88.05-88.049a88.25 88.25 0 0 0-1.02-13.409c19.464-4.03 39.588-6.075 60.047-6.075 20.515 0 40.613 2.041 60.048 6.068-.672 4.376-1.021 8.857-1.021 13.417 0 48.55 39.498 88.049 88.049 88.049 19.568 0 37.66-6.424 52.294-17.263 15.157 21.633 23.712 45.416 25.08 69.911-23.821 20.076-49.402 34.122-79.949 43.943-38.207 12.281-85.474 18.252-144.5 18.252s-106.292-5.971-144.499-18.253c-30.549-9.821-56.132-23.869-79.952-43.945 1.369-24.496 9.921-48.278 25.079-69.91zm199.372 244.067c-107.337 0-197.317-56.31-219.433-131.312 19.882 13.102 41.246 23.196 65.34 30.94 41.355 13.295 91.76 19.758 154.094 19.758s112.738-6.463 154.095-19.758c24.092-7.745 45.456-17.839 65.338-30.939-22.12 75.002-112.099 131.311-219.434 131.311z"/><use href="#B"/><use href="#B" x="294.153"/><path d="M219.852 256.624c-9.79 0-17.756 8.048-17.756 17.986s7.966 17.992 17.756 17.992c9.802 0 17.767-8.055 17.767-17.992s-7.965-17.986-17.767-17.986zm72.163-.14c-9.885 0-17.902 8.115-17.902 18.126s8.017 18.126 17.902 18.126c9.872 0 17.89-8.115 17.89-18.126s-8.018-18.126-17.89-18.126z"/><defs ><path id="B" d="M108.931 105.926c-11.45 0-20.738 9.401-20.738 20.998s9.288 20.998 20.738 20.998c11.436 0 20.725-9.401 20.725-20.998s-9.289-20.998-20.725-20.998z"/></defs></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+98
View File
@@ -0,0 +1,98 @@
// analytics.js — Аналитика и счетчики посещений для hypn0.xyz
// Версия: 1.0 | Дата: 2026-05-15
// Содержит: Google Analytics 4, Yandex.Metrika, Top.Mail.Ru
(function() {
'use strict';
// ============================================================================
// Google Analytics 4 (GA4)
// ID: GT-XXXXXXXX
// ============================================================================
(function() {
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=GT-XXXXXXXX';
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', 'GT-XXXXXXXX');
})();
// ============================================================================
// Yandex.Metrika (Яндекс.Метрика)
// ID: 12345678
// ============================================================================
(function() {
window.ym = window.ym || function(){
(window.ym.a = window.ym.a || []).push(arguments);
};
window.ym.l = 1 * new Date();
// Загружаем скрипт Метрики
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'https://mc.yandex.ru/metrika/tag.js';
document.head.appendChild(script);
// Инициализируем Метрику
window.ym(12345678, 'init', {
trackHash: true,
clickmap: true,
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true
});
})();
// ============================================================================
// Top.Mail.Ru counter (Рейтинг@Mail.ru)
// ID: 1234567
// ============================================================================
(function() {
var _tmr = window._tmr || (window._tmr = []);
_tmr.push({
id: "1234567",
type: "pageView",
start: (new Date()).getTime()
});
(function(d, w, id) {
if (d.getElementById(id)) return;
var ts = d.createElement("script");
ts.type = "text/javascript";
ts.async = true;
ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js";
var f = function() {
var s = d.getElementsByTagName("script")[0];
s.parentNode.insertBefore(ts, s);
};
if (w.opera == "[object Opera]") {
d.addEventListener("DOMContentLoaded", f, false);
} else {
f();
}
})(document, window, "tmr-code");
// Добавляем изображение для noscript
if (!window.noScriptAdded) {
window.noScriptAdded = true;
var noscriptDiv = document.createElement('div');
noscriptDiv.style.display = 'none';
noscriptDiv.innerHTML = '<img src="https://top-fwz1.mail.ru/counter?id=1234567;js=na" style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" />';
document.body.appendChild(noscriptDiv);
}
})();
})();
// Экспортируем gtag в глобальный контекст для возможности использования в коде
// window.gtag доступна через глобальную переменную
+3
View File
@@ -15,8 +15,11 @@ python = "^3.14"
pillow = "^12.3.0" pillow = "^12.3.0"
django = "^6.0.8" django = "^6.0.8"
django-environ = "^0.14.0" django-environ = "^0.14.0"
hashids = "^1.3.1"
gunicorn = "^26.0.0" gunicorn = "^26.0.0"
whitenoise = "^6.12.0" whitenoise = "^6.12.0"
etpgrf = "^0.1.6.post1"
pytils = "^0.4.4"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
django-debug-toolbar = "^7.0" django-debug-toolbar = "^7.0"
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Сборка CodeMirror 6 для админки Django
# Запуск из корня проекта: bash ./scripts/build-codemirror.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CODEMIRROR_DIR="$PROJECT_ROOT/frontend-assembly/codemirror"
OUTPUT_DIR="$PROJECT_ROOT/public/static/codemirror"
log() {
printf '[codemirror6] %s\n' "$*"
}
fail() {
printf '[codemirror6] %s\n' "$*" >&2
exit 1
}
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$CODEMIRROR_DIR/package.json" ]]; then
fail "Не найден package.json: $CODEMIRROR_DIR/package.json"
fi
if [[ ! -f "$CODEMIRROR_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $CODEMIRROR_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
# Создаём src/editor.js для CodeMirror
mkdir -p "$CODEMIRROR_DIR/src"
log "Создаю src/editor.js в $CODEMIRROR_DIR"
cat > "$CODEMIRROR_DIR/src/editor.js" <<'EOF'
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, lineNumbers, placeholder } from '@codemirror/view';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css';
import { solarizedDark, solarizedLight } from '@uiw/codemirror-theme-solarized';
const themeCompartment = new Compartment();
const processedForms = new Set();
function isDarkTheme() {
const rootTheme = document.documentElement.dataset.theme;
if (rootTheme === 'dark') return true;
if (rootTheme === 'light') return false;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function reconfigureTheme(view) {
view.dispatch({
effects: themeCompartment.reconfigure(isDarkTheme() ? solarizedDark : solarizedLight),
});
}
function initCodeMirrorEditors() {
document.querySelectorAll('textarea[data-codemirror-editor]').forEach((textarea) => {
const language = textarea.dataset.language || 'text';
const inputMode = textarea.dataset.inputMode || 'default';
let initialDoc = textarea.value ?? '';
const wrapper = document.createElement('div');
wrapper.className = 'cm6-editor-wrapper';
textarea.insertAdjacentElement('beforebegin', wrapper);
if (language === 'json') {
try {
const parsed = JSON.parse(initialDoc);
initialDoc = JSON.stringify(parsed, null, 2);
} catch (e) {
console.warn("CodeMirror: Initial content is not valid JSON, displaying as is.", e);
}
}
const syncTextarea = EditorView.updateListener.of((update) => {
if (update.docChanged) {
textarea.value = update.state.doc.toString();
}
});
// --- Собираем расширения в правильном порядке ---
const extensions = [
syntaxHighlighting(defaultHighlightStyle),
syncTextarea,
themeCompartment.of(isDarkTheme() ? solarizedDark : solarizedLight),
];
// Язык
if (language === 'javascript') extensions.push(javascript());
else if (language === 'css') extensions.push(css());
else if (language === 'json') extensions.push(json());
else if (language === 'html') extensions.push(html());
// Режим ввода
if (inputMode === 'url') {
extensions.push(placeholder('https://example.com/path'));
// Для URL не добавляем lineWrapping и lineNumbers
} else {
extensions.push(EditorView.lineWrapping);
if (!textarea.classList.contains('codemirror-no-lines')) {
extensions.push(lineNumbers());
}
}
const state = EditorState.create({
doc: initialDoc,
extensions,
});
const view = new EditorView({
state,
parent: wrapper,
});
textarea.cmView = view;
reconfigureTheme(view);
const observer = new MutationObserver(() => reconfigureTheme(view));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
});
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
colorScheme.addEventListener('change', () => reconfigureTheme(view));
const form = textarea.closest('form');
if (form && !processedForms.has(form)) {
form.addEventListener('submit', () => {
form.querySelectorAll('textarea[data-language="json"]').forEach(jsonTextarea => {
if (jsonTextarea.cmView) {
const prettyJson = jsonTextarea.cmView.state.doc.toString();
try {
const parsed = JSON.parse(prettyJson);
const minifiedJson = JSON.stringify(parsed);
jsonTextarea.value = minifiedJson;
} catch (e) {
console.warn("CodeMirror: Could not minify invalid JSON before submit.", e);
}
}
});
});
processedForms.add(form);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCodeMirrorEditors, { once: true });
} else {
initCodeMirrorEditors();
}
EOF
log "СОБИРАЮ CodeMirror 6"
cd "$CODEMIRROR_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю CodeMirror 6'
export CM6_OUTPUT_DIR="$OUTPUT_DIR"
npm run build
log 'Удаляю временные файлы (src/ и node_modules/)'
rm -rf src node_modules
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/editor.js"