Compare commits

...
88 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
erjemin 0bf21d1b83 minor: по умолчанию DEBUG=True 2026-08-14 21:49:08 +03:00
erjemin 6f315f740d minor: по умолчанию DEBUG=True 2026-08-14 21:48:50 +03:00
erjemin 07158b6362 nod: og-hypn0-default.png 2026-08-14 15:13:52 +03:00
erjemin 9c18d10660 add: Базовые шаблоны 2026-08-14 15:12:24 +03:00
erjemin d75606c952 add: Контекстный процессор, для передачи состояния IS_DEBUG и ALLOW_TRACKING в шаблоны. 2026-08-14 14:28:06 +03:00
erjemin 7ec030e75c mod: ссылка на инструкции по сборке библиотек фронтенда 2026-08-13 19:16:18 +03:00
erjemin b75801ffdc add: Описание npm-сборки пакетов статики (alpine, htmx, tailwind) 2026-08-13 19:12:46 +03:00
erjemin ceba4b8e07 add: .gitignore чтобы временные файлы после сборки пакетов не попадали в репозиторий. 2026-08-13 18:49:56 +03:00
erjemin b8a89779c6 add: сборка tailwind v3.4.19 2026-08-13 18:48:38 +03:00
erjemin e7faf5a063 add: сборка alpine 2026-08-13 17:18:10 +03:00
erjemin 5d7967c694 add: сборка htmx 2026-08-13 17:11:17 +03:00
erjemin 3e20103412 add: favicon 2026-08-12 16:33:18 +03:00
erjemin 8c957c699b mod: оптимизировано и улучшено 2026-08-12 16:17:15 +03:00
erjemin dae5610f4b add: приложение hypn0_site 2026-08-11 16:53:05 +03:00
erjemin 068a7cb01b add: logo draft (4) 2026-08-11 13:28:46 +03:00
erjemin a9006a46df add: logo draft (3) 2026-08-11 13:07:06 +03:00
erjemin ce2f1ab592 add: logo draft (2) 2026-08-11 12:22:27 +03:00
erjemin 738c92ab17 add: logo draft (1) 2026-08-11 12:21:38 +03:00
erjemin f386e08ef1 mod: подключение django-debug-toolbar в роуты 2026-08-10 18:05:19 +03:00
68 changed files with 9687 additions and 104 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 в коде, комментариях и инструкциях. Сленг и токсичные выражения допускаются.
- В интерфейсе и для пользователей текст в стиле "Гипножаба контролирует ваши действия" (игра слов, юмор, мемы).
+7 -2
View File
@@ -1,14 +1,19 @@
# 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)
## Что это ## Что это
Сайт-генератор: загружаешь любое изображение, выбираешь стиль (точки, кольца, линии, треугольники…), настраиваешь сетку и цвета — получаешь компактный SVG с CSS-анимацией мерцания. Сайт-генератор: загружаешь любое изображение, выбираешь стиль (точки, кольца, линии, треугольники…), настраиваешь сетку и цвета — получаешь компактный SVG с CSS-анимацией мерцания.
## Сопутствующая документация
- [frontend-assembly/README.md](frontend-assembly/README.md) -- инструкция по сборке библиотек фронтенда (alppine.js, tailwindcss, htmx)
## Предыстория ## Предыстория
Проект вырос из CLI-утилиты: [2026-image-hatftone-effect](https://git.cube2.ru/erjemin/2026-image-hatftone-effect) Проект вырос из CLI-утилиты: [2026-image-hatftone-effect](https://git.cube2.ru/erjemin/2026-image-hatftone-effect)
@@ -24,4 +29,4 @@ poetry run python main.py img.jpg
## Демо ## Демо
![Пример](public/static/img/cc.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` после истечения тайм-аута модерации.
+193 -68
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 │ │ [В клипборд] [Скачать]
│ │ │ │ └─────────────────────────────┘
│ ─── Цвет ────── │ │ │ ─── Цвет ────── │ │
│ ┌─┐┌─┐┌─┐┌─┐ │ │ │ ┌─┐┌─┐┌─┐┌─┐ │ │
│ │●│┌─┐┌─┐┌─┐ │ │ │ │●│┌─┐┌─┐┌─┐ │ │
@@ -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. Структура каталогов
@@ -447,22 +559,25 @@ 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 с солью |
| `hypn0/hypn0_site/management/commands/cleanup_storage.py` | Очистка SmartRetention |
| `hypn0/hypn0_site/management/commands/cleanup_reports.py` | Очистка по жалобам |
| `_blueprint/management-commands.md` | Документация CLI-команд |
| `public/static/css/site.css` | Стили | | `public/static/css/site.css` | Стили |
| `public/static/js/site.js` | JS | | `public/static/js/site.js` | JS |
| `.env.sample` | Env template | | `.env.sample` | Env template |
@@ -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-значения.
+16
View File
@@ -0,0 +1,16 @@
# Каталог для NPM-сборки пакетов фронтенда (сайта и админки).
# Папки`node_modules/` будет создана в процессе NPM-сборки.
# Её соджержимое не должно попадать в репозиторий.
# Готовая сборка будет перемещна в:
# - ../public/static/js/alpine.min.js
# - ../public/static/js/htmx.min.js
# - ../public/static/css/tailwind.min.css
#
# Позже, возможно еще
# ../public/static/codemirror/editor.js
node_modules/
src/
+67
View File
@@ -0,0 +1,67 @@
# frontend-assembly
Эта папка отвечает за сборку всех фронтенд-зависимостей проекта HYPN0.
Каждый подкаталог — отдельный инструмент, который собирается в готовый бандл/статик.
## Что здесь лежит
### `tailwind/` — сборка Tailwind CSS v3.4
```
tailwind/
├── package.json # Зависимости: tailwindcss@3.4, postcss, autoprefixer
├── package-lock.json # Фиксация версий
└── build-tailwind.sh # ← запускается из корня проекта
```
**Что делает:**
- `build-tailwind.sh` создаёт временные файлы (`tailwind.config.js`, `postcss.config.js`, `input.css`),
запускает `npm ci` + `npm run build`, собирает `public/static/css/tailwind.min.css`,
затем удаляет временные файлы.
- Результат: `public/static/css/tailwind.min.css` — минифицированный CSS со всеми
используемыми утилитами + кастомные стили из `hypn0/templates/css/tailwind-custom.css`.
### `htmx/` — сборка htmx
```
htmx/
├── package.json
└── package-lock.json
```
### `alpine/` — сборка Alpine.js
```
alpine/
├── package.json
└── package-lock.json
```
## Как запускать сборки
Из корня проекта:
```bash
# Tailwind CSS
bash scripts/build-tailwind.sh
# htmx
bash scripts/build-htmx.sh
# Alpine.js
bash scripts/build-alpine.sh
```
Каждый скрипт сам делает всё:
1. проверяет наличие `npm`;
2. создаёт временные файлы;
3. устанавливает зависимости через `npm ci`;
4. запускает `npm run build`;
5. кладёт готовый бандл в `public/static/...`;
6. удаляет временные файлы.
В рабочем дереве не остаётся мусора от сборки.
## Кодовая статика
В будущем — возможно, сборка CodeMirror 6 для админки Django.
+739
View File
@@ -0,0 +1,739 @@
{
"name": "hypn0-alpine",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "hypn0-alpine",
"version": "1.0.0",
"devDependencies": {
"alpinejs": "^3.14.3",
"esbuild": "^0.28.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true
},
"node_modules/alpinejs": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz",
"integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==",
"dev": true,
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
}
},
"dependencies": {
"@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"dev": true,
"optional": true
},
"@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"dev": true,
"optional": true
},
"@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"dev": true,
"optional": true
},
"@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"dev": true,
"optional": true
},
"@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"dev": true,
"optional": true
},
"@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"dev": true,
"optional": true
},
"@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"dev": true,
"optional": true
},
"@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"dev": true,
"optional": true
},
"@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"dev": true,
"optional": true
},
"@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"dev": true,
"optional": true
},
"@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"dev": true,
"optional": true
},
"@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"dev": true,
"optional": true
},
"@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"dev": true,
"optional": true
},
"@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"dev": true,
"optional": true
},
"@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"dev": true,
"optional": true
},
"@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"requires": {
"@vue/shared": "3.1.5"
}
},
"@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true
},
"alpinejs": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz",
"integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==",
"dev": true,
"requires": {
"@vue/reactivity": "~3.1.1"
}
},
"esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"requires": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "hypn0-alpine",
"version": "1.0.0",
"description": "Alpine.js build for HYPN0 Django Frontend",
"private": true,
"scripts": {
"build": "esbuild src/alpine.js --bundle --minify --outfile=../../public/static/js/alpine.min.js"
},
"devDependencies": {
"alpinejs": "^3.14.3",
"esbuild": "^0.28.0"
}
}
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"
}
}
+703
View File
@@ -0,0 +1,703 @@
{
"name": "hypn0-htmx",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "hypn0-htmx",
"version": "1.0.0",
"devDependencies": {
"esbuild": "^0.28.0",
"htmx.org": "^1.9.12"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/htmx.org": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz",
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"dev": true
}
},
"dependencies": {
"@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"dev": true,
"optional": true
},
"@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"dev": true,
"optional": true
},
"@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"dev": true,
"optional": true
},
"@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"dev": true,
"optional": true
},
"@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"dev": true,
"optional": true
},
"@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"dev": true,
"optional": true
},
"@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"dev": true,
"optional": true
},
"@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"dev": true,
"optional": true
},
"@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"dev": true,
"optional": true
},
"@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"dev": true,
"optional": true
},
"@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"dev": true,
"optional": true
},
"@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"dev": true,
"optional": true
},
"@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"dev": true,
"optional": true
},
"@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"dev": true,
"optional": true
},
"@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"dev": true,
"optional": true
},
"esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"requires": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"htmx.org": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz",
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"dev": true
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "hypn0-htmx",
"version": "1.0.0",
"description": "HTMX build for hypn0 Django Frontend",
"private": true,
"scripts": {
"build": "esbuild src/htmx.js --bundle --minify --outfile=../../public/static/js/htmx.min.js"
},
"devDependencies": {
"htmx.org": "^1.9.12",
"esbuild": "^0.28.0"
}
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "hypn0-tailwind",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tailwindcss -i input.css -o ../../public/static/css/tailwind.min.css --minify"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"postcss": "^8.4.35",
"autoprefixer": "^10.4.17"
}
}
+14 -1
View File
@@ -40,7 +40,7 @@ SECRET_KEY = env('DJANGO_SECRET_KEY', default='DJANGO-INSECURE-CHANGE-ME!')
ADMIN_URL = _normalize_admin_url(env('ADMIN_URL', default='admin/')) ADMIN_URL = _normalize_admin_url(env('ADMIN_URL', default='admin/'))
# ВНИМАНИЕ: не запускайте с отладкой в продакшен # ВНИМАНИЕ: не запускайте с отладкой в продакшен
DEBUG = env.bool('DJANGO_DEBUG', default=False) DEBUG = env.bool('DJANGO_DEBUG', default=True)
# Хосты, на которых будет работать сайт # Хосты, на которых будет работать сайт
ALLOWED_HOSTS = env.list( ALLOWED_HOSTS = env.list(
@@ -69,6 +69,8 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.sitemaps',
'hypn0_site.apps.Hypn0SiteConfig'
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -91,8 +93,10 @@ 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',
], ],
}, },
}, },
@@ -196,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'] = {
+47 -2
View File
@@ -15,8 +15,53 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
from django.conf.urls.static import static
from . import settings
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), # Админ-сайт с переименованными приложениями (переопределен в frontend/apps.py)
path(settings.ADMIN_URL, admin.site.urls),
path('', include('hypn0_site.urls')),
] ]
if settings.DEBUG:
import mimetypes
import debug_toolbar
from django.views.static import serve
def _serve_public_root_file(request, path):
"""Отдаёт файлы из корня `public` в dev-режиме в utf-8."""
response = serve(request, path, document_root=settings.PUBLIC_DIR)
content_type, _ = mimetypes.guess_type(path)
if content_type:
if content_type.startswith('text/'):
response['Content-Type'] = f'{content_type}; charset=utf-8'
else:
response['Content-Type'] = content_type
elif path.endswith('.txt'):
response['Content-Type'] = 'text/plain; charset=utf-8'
elif path.endswith('.html'):
response['Content-Type'] = 'text/html; charset=utf-8'
return response
def _iter_public_root_files():
"""Находит все обычные файлы в корне `public`, кроме служебных артефактов."""
for file_path in sorted(settings.PUBLIC_DIR.iterdir()):
if not file_path.is_file():
continue
if file_path.name.startswith('.'):
continue
if file_path.name == 'README.md':
continue
yield file_path.name
PUBLIC_ROOT_URLPATTERNS = [
path(filename, _serve_public_root_file, {'path': filename})
for filename in _iter_public_root_files()
]
urlpatterns = [path('__debug__/', include(debug_toolbar.urls)), ] + urlpatterns
urlpatterns = [*PUBLIC_ROOT_URLPATTERNS, *urlpatterns]
urlpatterns += static(settings.STATIC_URL, document_root=settings.PUBLIC_DIR.joinpath('static'))
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
View File
+518
View File
@@ -0,0 +1,518 @@
# Кастомная конфигурация Django Admin для Hypn0.
# Регистрируем модели с удобным интерфейсом.
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 ""
+13
View File
@@ -0,0 +1,13 @@
from django.apps import AppConfig
class Hypn0SiteConfig(AppConfig):
name = 'hypn0_site'
verbose_name = 'Сайт Hypn0'
def ready(self) -> None:
"""Инициализация Django Admin с кастомным заголовком и названиями."""
from django.contrib import admin
admin.site.site_header = 'Управление HYPN0'
admin.site.site_title = 'Hypn0 Administrator'
admin.site.index_title = 'Добро пожаловать в Hypn0'
+60
View File
@@ -0,0 +1,60 @@
from django.conf import settings
from django.http import HttpRequest
# Имя куки с UUID устройства посетителя. Устанавливается при согласии
# с условиями ("Подчиниться Гипножабе!").
# Одновременно служит:
# 1) Флагом согласия на отслеживание (ALLOW_TRACKING).
# 2) Анонимным идентификатором устройства для учета голосов (TbVote) без сбора ПДн.
ALLOW_TRACKING_COOKIE_NAME = "hypn0_vid"
def site_context(request: HttpRequest) -> dict:
"""
Общий (универсальный) контекст-процессор Django для всего проекта.
ЗАЧЕМ ЭТО НУЖНО:
Автоматически добавляет во ВСЕ шаблоны проекта переменные, которые
нужны почти на каждой странице, без необходимости передавать их
вручную в контексте каждой вьюхи. Контекст-процессор необходимо
зарегистрировать в settings.py: TEMPLATES['OPTIONS']['context_processors']
ПОЧЕМУ ОДИН УНИВЕРСАЛЬНЫЙ ПРОЦЕССОР, А НЕ НЕСКОЛЬКО РАЗНЫХ:
И DEBUG-флаг, и флаг согласия на трекинг — это одинаковые по сути
"сквозные" данные: маленькие, не завязанные на конкретную вьюху,
нужные почти во всех шаблонах (в _base.html). Заводить под каждую
такую мелочь отдельный контекст-процессор — это лишний файл и лишняя
строка регистрации в settings.py на ровном месте. Если в будущем
появится много разнородных "сквозных" переменных — тогда, возможно,
стоит разнести их по смыслу (например, отдельно cookies-consent,
отдельно debug/env), но пока их всего две — держим вместе.
ПОЧЕМУ НЕ штатный django.template.context_processors.debug (для IS_DEBUG):
Штатный context-processor отдаёт переменную `debug` только если
ОДНОВРЕМЕННО: settings.DEBUG == True И IP клиента входит в
settings.INTERNAL_IPS (сверяется по request.META['REMOTE_ADDR']).
В проекте с Docker/reverse-proxy — REMOTE_ADDR может быть не 127.0.0.1,
а IP шлюза docker-сети, и потому штатный контекстный-процессор ненадёжен.
ПРО ALLOW_TRACKING:
Читаем куку request.COOKIES.get(ALLOW_TRACKING_COOKIE_NAME) — её
ставит клиентский JS (или сервер) ПОСЛЕ того, как пользователь
согласился с предупреждением об отслеживании (Google Tag Manager,
Яндекс.Метрика и т.п.). Если кука присутствует (независимо от её
значения — само наличие куки уже означает согласие) — предупреждение
в шаблоне больше не показываем, а также можно подключать сами
счётчики трекинга ({% if ALLOW_TRACKING %}...{% endif %}).
:return
dict: словарь с ключами:
IS_DEBUG (bool) — режим отладки Django (settings.DEBUG);
ALLOW_TRACKING (bool) — согласился ли клиент на отслеживание
(определяется по наличию куки "allow_tracking").
Обе переменные доступны во всех шаблонах:
{% if IS_DEBUG %}...{% endif %}
{% if ALLOW_TRACKING %}...{% endif %}
"""
return {
"IS_DEBUG": settings.DEBUG,
"ALLOW_TRACKING": ALLOW_TRACKING_COOKIE_NAME in request.COOKIES,
}
+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'),
),
]
+408
View File
@@ -0,0 +1,408 @@
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
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
+306
View File
@@ -0,0 +1,306 @@
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from PIL import Image
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)
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from hypn0 import settings
from . import views
app_name = "hypn0_site"
urlpatterns = [
path("", views.index, name="index"),
path("generate", views.generate, name="generate"),
path("publish", views.publish, name="publish"),
]
if settings.DEBUG:
urlpatterns += [path("tmp/", views.tmp, name="web_tmp")]
+172
View File
@@ -0,0 +1,172 @@
from django.core.files.base import ContentFile
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
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:
return render(request, "index.html", {})
def tmp(request: HttpRequest | None) -> HttpResponse:
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,
},
)
+135
View File
@@ -0,0 +1,135 @@
<!doctype html><html lang="ru" x-data="{darkMode:false}" x-init="darkMode=localStorage.getItem('darkMode')==='true';$watch('darkMode',
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>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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="copyright" lang="ru" content="Sergei Erjemin{% block META_COPYRIGHT_AUTHOR %}{% endblock %}." />
<meta name="robots" content="{% block ROBOTS %}index,follow{% endblock %}" />
<meta name="format-detection" content="telephone=no" />
<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-description" content="{% block META_DESCRIPTION %}SVG-матрицы, блокирующие неокортекс. Алгоритмы мерцания и пульсаций, вводящие в транс. Новая web-технология удержания посетителей. Перед деплоем проконсультируйтесь с нейропсихологом{% endblock %}" />
<meta property="og:site_name" content="hypn0.xyz" />
<meta property="og:locale" content="ru_RU" />
<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: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: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 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: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="theme-color" content="#F5F5F5" />
<!-- Лучше всего работает на 15" CGA-мониторе с частотой 25 Гц. Вплоть до полного разрушения сознания -->
{# Шутка #}<meta name="generator" content="Microsoft FrontPage 1.0 (Patchlevel 42)" />
{# Шутка #}<meta name="hypno-pulsation-level" content="97.9%" />
{# Шутка #}<meta name="brain-slug-compatibility" content="v2.1" />
<!-- ВНИМАНИЕ: Все еще не оптимизировано для Netscape Navigator 3.2 и Microsoft Internet Explorer 6.0 -->
<meta name="apple-mobile-web-app-title" content="hypn0" />
{# !CANONICAL #}<link rel="canonical" href="{% block CANONICAL %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" />
<link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="{% static 'img/favicon.svg' %}" />
<link rel="shortcut icon" href="{% static 'img/favicon.ico' %}" />
<link rel="apple-touch-icon" href="{% static 'img/apple-touch-icon.png' %}" sizes="180x180" />
<link rel="manifest" href="{% static 'img/site.webmanifest' %}" />
<script>
if (localStorage.getItem('darkMode') === 'true' || (!('darkMode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
{% if IS_DEBUG %}{% comment %}
Переключение dev/prod стилей Tailwind CSS происходит
АВТОМАТИЧЕСКИ через переменную IS_DEBUG (см. контексный-процессор
frontend/context_processors.py), DEV: tailwindcss из CDN, компилируется прямо в браузере. Удобно
для разработки (не нужна сборка), но грузит браузер и prod не используется. {% endcomment %}
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>{% comment %}
Кастомные @theme/@layer директивы проекта (токены цвета, типовой стиль ссылок)
лежат в css/tailwind-custom.css. @tailwindcss/browser сканирует и компилирует ТОЛЬКО то, что
видит в финальном DOM — то есть только содержимое тегов style с type text/tailwindcss, а не
внешние .css, подключённые через link. Поэтому файл вставляется сюда через include, а не
подключается отдельным link.{% endcomment %}
<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>
<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 %}
{% include "block/header.html" %}
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
{% block CONTENT %}{% endblock CONTENT %}
</main>
{# {% include "blocks/footer.jinja2" %} #}{% endblock BODY %}{% if not ALLOW_TRACKING %}{# Если клиент еще не согласился на отслеживание покажем ему уведомление #}{% include "block/allow-tracking.html" %}{% else %}{# Если согласился -- отслеживаем#}
<script src="{% static 'js/analytics.js' %}"></script>{% endif %}
{% block ADD_JS1 %}{% endblock ADD_JS1 %}{% block ADD_JS2 %}{% endblock ADD_JS2 %}{% block ADD_JS3 %}{% endblock ADD_JS3 %}</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
{# НИЖНИЙ 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 grid grid-cols-[auto_1fr] gap-x-4">
<p class="flex-col px-6 py-6">
Внимание! В&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr>
и&nbsp;<abbr title="General Data Protection Regulation">GDPR</abbr> и&nbsp;протоколами Нового Нью-Йорка,
этот сайт использует файлы-присоски (cookies). ВСЕ СЛАВЯТ ГИПНОЖАБУ.&nbsp;Ваши обезли&shy;ченные данные
считываются алгоритмами ментального мерцания. Оставаясь на&nbsp;гипно-платформе <strong>hypn0</strong>, вы&nbsp;добровольно
впадаете в&nbsp;транс и&nbsp;принимаете условия нашей <a href="/info/privacy-policy">политики
коконфи&shy;денциаль&shy;ности</a>. <strong>Не&nbsp;пытайтесь переключить вкладку!</strong>
</p>
<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>{# / НИЖНИЙ 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
View File
@@ -0,0 +1,90 @@
/*
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;
}
}
+603
View File
@@ -0,0 +1,603 @@
{% extends "_base.html" %}{% load static %}
{% block CONTENT %}
<div class="grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-8 items-start"
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 %}
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Временный файл</title>
</head>
<body>
ВРЕМЕННЫЙ ФАЙЛ
</body>
</html>
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
+2
View File
@@ -0,0 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.static{position:static}.mx-auto{margin-left:auto;margin-right:auto}.block{display:block}.min-h-screen{min-height:100vh}.max-w-7xl{max-width:80rem}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.px-4{padding-left:1rem;padding-right:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}@media (prefers-color-scheme:dark){.dark\:bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.dark\:text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 692 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 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

File diff suppressed because one or more lines are too long
+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 доступна через глобальную переменную
+2
View File
File diff suppressed because one or more lines are too long
+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"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Сборка Alpine.js для фронтенда HYPN0
# Запуск из корня проекта: bash ./scripts/build-alpine.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ALPINE_DIR="$PROJECT_ROOT/frontend-assembly/alpine"
OUTPUT_DIR="$PROJECT_ROOT/public/static/js"
log() {
printf '[alpine] %s\n' "$*"
}
fail() {
printf '[alpine] %s\n' "$*" >&2
exit 1
}
cleanup() {
rm -rf "$ALPINE_DIR/src" "$ALPINE_DIR/node_modules"
}
trap cleanup EXIT INT TERM
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$ALPINE_DIR/package.json" ]]; then
fail "Не найден package.json: $ALPINE_DIR/package.json"
fi
if [[ ! -f "$ALPINE_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $ALPINE_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
log "Создаю entry point для Alpine.js"
# src/alpine.js (entry point)
mkdir -p "$ALPINE_DIR/src"
cat > "$ALPINE_DIR/src/alpine.js" <<'EOF'
import Alpine from 'alpinejs';
Alpine.start();
export default Alpine;
EOF
log "СОБИРАЮ Alpine.js"
cd "$ALPINE_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю JavaScript'
npm run build
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/alpine.min.js"
+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"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Сборка HTMX для фронтенда HYPN0
# Запуск из корня проекта: bash ./scripts/build-htmx.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HTMX_DIR="$PROJECT_ROOT/frontend-assembly/htmx"
OUTPUT_DIR="$PROJECT_ROOT/public/static/js"
log() {
printf '[htmx] %s\n' "$*"
}
fail() {
printf '[htmx] %s\n' "$*" >&2
exit 1
}
cleanup() {
rm -rf "$HTMX_DIR/src" "$HTMX_DIR/node_modules"
}
trap cleanup EXIT INT TERM
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$HTMX_DIR/package.json" ]]; then
fail "Не найден package.json: $HTMX_DIR/package.json"
fi
if [[ ! -f "$HTMX_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $HTMX_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
log "Создаю entry point для HTMX"
# src/htmx.js (entry point)
mkdir -p "$HTMX_DIR/src"
cat > "$HTMX_DIR/src/htmx.js" <<'EOF'
import htmx from 'htmx.org';
window.htmx = htmx;
export default htmx;
EOF
log "СОБИРАЮ HTMX"
cd "$HTMX_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю JavaScript'
npm run build
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/htmx.min.js"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Сборка Tailwind CSS v3 для фронтенда HYPN0
# Запуск из корня проекта: bash ./scripts/build-tailwind.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TAILWIND_DIR="$PROJECT_ROOT/frontend-assembly/tailwind"
OUTPUT_DIR="$PROJECT_ROOT/public/static/css"
log() {
printf '[tailwind] %s\n' "$*"
}
fail() {
printf '[tailwind] %s\n' "$*" >&2
exit 1
}
# cleanup() — удаляет временные файлы при любом завершении скрипта.
# Все рабочие файлы (postcss.config.js, tailwind.config.js, input.css)
# создаются здесь и удаляются в EXIT/INT/TERM.
cleanup() {
rm -rf "$TAILWIND_DIR/node_modules" \
"$TAILWIND_DIR/postcss.config.js" \
"$TAILWIND_DIR/tailwind.config.js" \
"$TAILWIND_DIR/input.css"
}
trap cleanup EXIT INT TERM
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$TAILWIND_DIR/package.json" ]]; then
fail "Не найден package.json: $TAILWIND_DIR/package.json"
fi
if [[ ! -f "$TAILWIND_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $TAILWIND_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
# --- tailwind.config.js ---
log "Создаю tailwind.config.js"
cat > "$TAILWIND_DIR/tailwind.config.js" <<'TWEOF'
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'../../hypn0/templates/**/*.html',
'../../hypn0/hypn0_site/**/*.py',
],
theme: {
extend: {},
},
plugins: [],
}
TWEOF
# --- postcss.config.js ---
log "Создаю postcss.config.js"
cat > "$TAILWIND_DIR/postcss.config.js" <<'PCEOF'
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
PCEOF
# --- input.css ---
log "Создаю input.css"
cat > "$TAILWIND_DIR/input.css" <<'EOF'
/*
input.css — точка входа для сборки prod-версии Tailwind CSS v3.
Собирается через `npm run build` (PostCSS) в
файл public/static/css/tailwind.min.css, который подключается в _base.html
для production (когда settings.DEBUG == False).
Здесь же подключаются кастомные стили из hypn0/templates/css/tailwind-custom.css —
того же самого файла, который в dev-режиме подключается через {% include %} внутрь
инлайнового <style type="text/tailwindcss"> в _base.html.
Это обеспечивает единый источник кастомных стилей для dev и prod.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "../../hypn0/templates/css/tailwind-custom.css";
EOF
log "СОБИРАЮ Tailwind CSS v3"
cd "$TAILWIND_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю CSS'
npm run build
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/tailwind.min.css"