mod: ядро (04) SVG-генерация записывается в базу
This commit is contained in:
@@ -260,3 +260,24 @@ def generate_halftone_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
|
||||
+100
-1
@@ -5,7 +5,9 @@ from django.urls import reverse
|
||||
from PIL import Image
|
||||
|
||||
from .forms import HalftoneGenerateForm
|
||||
from .services.halftone import encode_to_base36, generate_halftone_svg
|
||||
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):
|
||||
@@ -205,3 +207,100 @@ class HalftoneViewTests(TestCase):
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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:
|
||||
|
||||
+104
-1
@@ -1,10 +1,13 @@
|
||||
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 .services.halftone import generate_halftone_svg
|
||||
from .models import TbHypn0Item
|
||||
from .services.halftone import generate_halftone_svg, prepare_gallery_svg
|
||||
from .services.naming import generate_hypno_title
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -65,5 +68,105 @@ def generate(request: HttpRequest) -> HttpResponse:
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -12,15 +12,40 @@
|
||||
{{ 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">
|
||||
<button id="like-to-gallery" type="button" title="Нравится!"
|
||||
class="pointer-events-auto 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 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>
|
||||
<!-- Плавающий блок кандидата в галерею -->
|
||||
<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 %}
|
||||
Reference in New Issue
Block a user