Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3584e96e | ||
|
|
58c1ee46df | ||
|
|
19d383dc89 | ||
|
|
457c33725a | ||
|
|
13641eae88 | ||
|
|
6f9da30040 | ||
|
|
1d5306a48e | ||
|
|
8a7de1f062 | ||
|
|
752f1506e3 | ||
|
|
ba1f1c2a0b | ||
|
|
3128ae4e2d | ||
|
|
4917e04e5a | ||
|
|
8bd5a54cf5 | ||
|
|
05bb583d8a | ||
|
|
f015351c42 | ||
|
|
1a285a61c6 | ||
|
|
200b81987c | ||
|
|
27049e9ae0 | ||
|
|
b63dbb6bd2 | ||
|
|
b23784b7ad | ||
|
|
87c8e79630 | ||
|
|
9777c93cb4 | ||
|
|
bb4ec185ff | ||
|
|
3cef695782 | ||
|
|
cc3a17e792 | ||
|
|
2bec2e860e | ||
|
|
381ec3eaf0 | ||
|
|
eff510eb4b | ||
|
|
99c317431e | ||
|
|
e49154090e | ||
|
|
31fb267d98 | ||
|
|
72855a2c32 | ||
|
|
816744bbfc | ||
|
|
6850df10e2 | ||
|
|
5ec89c323d | ||
|
|
d430ac5f0a | ||
|
|
4005373755 | ||
|
|
6c23be1f4b | ||
|
|
93200a698a | ||
|
|
5e8a0dbb8b | ||
|
|
7591913c68 | ||
|
|
147f3b4153 | ||
|
|
71f352d003 | ||
|
|
eedc304560 | ||
|
|
9ccc6fed7b | ||
|
|
c65509f14a | ||
|
|
a6a9d3dc48 | ||
|
|
8ed24d3291 | ||
|
|
60438d05bd | ||
|
|
bbd8144c70 | ||
|
|
44b5085b99 | ||
|
|
c0cb0a2af9 | ||
|
|
c791df7b0d | ||
|
|
e8cfaf67b7 | ||
|
|
90edb6c8a3 | ||
|
|
096e55828d | ||
|
|
60e04910a1 | ||
|
|
6bf884223f | ||
|
|
8dc84238e4 | ||
|
|
a4fff36407 | ||
|
|
811037fe80 | ||
|
|
49d0d5aaa4 | ||
|
|
8605d6991c | ||
|
|
ee4eb75617 | ||
|
|
e7dbd05eaa | ||
|
|
40c38f08cf | ||
|
|
7e7c39c6d5 | ||
|
|
a5f2d43db6 | ||
|
|
815d6c9e41 | ||
|
|
0bf21d1b83 | ||
|
|
6f315f740d | ||
|
|
07158b6362 | ||
|
|
9c18d10660 | ||
|
|
d75606c952 | ||
|
|
7ec030e75c | ||
|
|
b75801ffdc | ||
|
|
ceba4b8e07 | ||
|
|
b8a89779c6 | ||
|
|
e7faf5a063 | ||
|
|
5d7967c694 | ||
|
|
3e20103412 | ||
|
|
8c957c699b | ||
|
|
dae5610f4b | ||
|
|
068a7cb01b | ||
|
|
a9006a46df | ||
|
|
ce2f1ab592 | ||
|
|
738c92ab17 | ||
|
|
f386e08ef1 |
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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 в коде, комментариях и инструкциях. Сленг и токсичные выражения допускаются.
|
||||||
|
- В интерфейсе и для пользователей текст в стиле "Гипножаба контролирует ваши действия" (игра слов, юмор, мемы).
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
# HypnoSVG
|
# Hypn0
|
||||||
|
|
||||||
Генератор гипнотических SVG-халтонов из изображений. Загрузи фото → настраивай фигуры, сетки, цвета и анимации → получи залипательную графику.
|
Генератор гипнотических SVG-халтонов из изображений. Загрузи фото → настраивай фигуры, сетки, цвета и анимации → получи залипательную графику.
|
||||||
|
|
||||||
[](https://python.org)
|
[](https://python.org)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](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
|
|||||||
|
|
||||||
## Демо
|
## Демо
|
||||||
|
|
||||||

|

|
||||||
@@ -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` после истечения тайм-аута модерации.
|
||||||
@@ -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-значения.
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'] = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 полей (заголовок, тизер, контент) "
|
||||||
|
)
|
||||||
|
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/>(«ёлочки» для русского, “лапки” для английского) "
|
||||||
|
)
|
||||||
|
etp_hyphenation = forms.BooleanField(
|
||||||
|
label="Расставлять переносы",
|
||||||
|
initial=True,
|
||||||
|
required=False,
|
||||||
|
help_text="Расставлять мягкие переносы (&shy;) <br/>"
|
||||||
|
"в словах длиннее <b>14</b> символов "
|
||||||
|
)
|
||||||
|
etp_sanitize = forms.BooleanField(
|
||||||
|
label="Очистка HTML",
|
||||||
|
initial=False,
|
||||||
|
required=False,
|
||||||
|
help_text="Удалять весь HTML из исходного текста "
|
||||||
|
)
|
||||||
|
etp_hanging_punctuation = forms.BooleanField(
|
||||||
|
label="Висячая пунктуация",
|
||||||
|
initial=False,
|
||||||
|
required=False,
|
||||||
|
help_text="Выносить пунктуацию в начало строк<br/> "
|
||||||
|
"(только для заголовков... для тизера и контента отключается автоматически) "
|
||||||
|
)
|
||||||
|
etp_mode = forms.ChoiceField(
|
||||||
|
label="Режим вывода",
|
||||||
|
choices=[('mixed', 'Смешанный (Mixed)'), ('unicode', 'Юникод (Unicode)'), ('mnemonic', 'Мнемоники')],
|
||||||
|
initial='mixed',
|
||||||
|
required=False,
|
||||||
|
help_text="Формат спецсимволов (например, кавычек, тире, многоточий) в HTML: смешанный, юникод"
|
||||||
|
" или мнемоники "
|
||||||
|
)
|
||||||
|
|
||||||
|
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 "—"
|
||||||
@@ -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'
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Services package for hypn0_site
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")]
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
Внимание! В соответствии с 152-<abbr title="Федеральный закон">ФЗ</abbr>
|
||||||
|
и <abbr title="General Data Protection Regulation">GDPR</abbr> и протоколами Нового Нью-Йорка,
|
||||||
|
этот сайт использует файлы-присоски (cookies). ВСЕ СЛАВЯТ ГИПНОЖАБУ. Ваши обезли­ченные данные
|
||||||
|
считываются алгоритмами ментального мерцания. Оставаясь на гипно-платформе <strong>hypn0</strong>, вы добровольно
|
||||||
|
впадаете в транс и принимаете условия нашей <a href="/info/privacy-policy">политики
|
||||||
|
коконфи­денциаль­ности</a>. <strong>Не пытайтесь переключить вкладку!</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 О КУКУХ И ОТСЛЕЖИВАНИИ #}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 или кликни</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">100–999 лайков</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 %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Временный файл</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
ВРЕМЕННЫЙ ФАЙЛ
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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))}}
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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 |
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -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 |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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 доступна через глобальную переменную
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||