add: core (01) драфт... работает!! Генерация SVG проходит!!!
This commit is contained in:
@@ -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=20,
|
||||||
|
max_value=200,
|
||||||
|
initial=80,
|
||||||
|
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
|
||||||
+190
-2
@@ -1,3 +1,191 @@
|
|||||||
from django.test import TestCase
|
import io
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
# Create your tests here.
|
from .forms import HalftoneGenerateForm
|
||||||
|
from .services.halftone import encode_to_base36, generate_halftone_svg
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
# Проверяем, что 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")
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
|
||||||
from hypn0 import settings
|
from hypn0 import settings
|
||||||
|
from . import views
|
||||||
|
|
||||||
app_name = "hypn0_site"
|
app_name = "hypn0_site"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="index"),
|
path("", views.index, name="index"),
|
||||||
|
path("generate", views.generate, name="generate"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += [path('tmp/', views.tmp, name='web_tmp')]
|
urlpatterns += [path("tmp/", views.tmp, name="web_tmp")]
|
||||||
|
|||||||
@@ -1,11 +1,69 @@
|
|||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.http import HttpRequest, HttpResponse, Http404
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
# Create your views here.
|
from .forms import HalftoneGenerateForm
|
||||||
|
from .services.halftone import generate_halftone_svg
|
||||||
|
|
||||||
|
|
||||||
|
@ensure_csrf_cookie
|
||||||
def index(request: HttpRequest | None) -> HttpResponse:
|
def index(request: HttpRequest | None) -> HttpResponse:
|
||||||
return render(request, 'index.html', {})
|
return render(request, "index.html", {})
|
||||||
|
|
||||||
|
|
||||||
def tmp(request: HttpRequest | None) -> HttpResponse:
|
def tmp(request: HttpRequest | None) -> HttpResponse:
|
||||||
return render(request, 'tmp.html', {})
|
return render(request, "tmp.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def generate(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
HTMX-эндпоинт для генерации гипнотического полутонового SVG на лету.
|
||||||
|
Не сохраняет данные в БД (Zero-Disk / Zero-PII).
|
||||||
|
"""
|
||||||
|
form = HalftoneGenerateForm(request.POST, request.FILES)
|
||||||
|
|
||||||
|
if not form.is_valid():
|
||||||
|
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации"
|
||||||
|
return render(request, "block/preview.html", {"error": error_msg})
|
||||||
|
|
||||||
|
image_file = form.cleaned_data["image"]
|
||||||
|
shape = form.cleaned_data["shape"]
|
||||||
|
cols = form.cleaned_data["cols"]
|
||||||
|
max_radius = form.cleaned_data["max_radius"]
|
||||||
|
blink = form.cleaned_data["blink"]
|
||||||
|
rotation = form.cleaned_data["rotation"]
|
||||||
|
scale = form.cleaned_data["scale"]
|
||||||
|
angle = form.cleaned_data["angle"]
|
||||||
|
|
||||||
|
# Извлечение основного цвета (поддержка монотонной схемы)
|
||||||
|
colors = request.POST.getlist("colors")
|
||||||
|
primary_color = colors[0] if colors and colors[0] else "#a855ff"
|
||||||
|
|
||||||
|
try:
|
||||||
|
svg_content = generate_halftone_svg(
|
||||||
|
image=image_file,
|
||||||
|
cols=cols,
|
||||||
|
max_radius=max_radius,
|
||||||
|
shape=shape,
|
||||||
|
color=primary_color,
|
||||||
|
blink=blink,
|
||||||
|
rotation=rotation,
|
||||||
|
scale=scale,
|
||||||
|
angle=angle,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"block/preview.html",
|
||||||
|
{"error": f"Сбой в матрице гипноза: {str(e)}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"block/preview.html",
|
||||||
|
{
|
||||||
|
"svg_content": svg_content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -110,9 +110,21 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
|
|||||||
{% endblock EXTRA_LD_JSON %}</script>
|
{% endblock EXTRA_LD_JSON %}</script>
|
||||||
<script src="{% static 'js/alpine.min.js' %}" defer></script>
|
<script src="{% static 'js/alpine.min.js' %}" defer></script>
|
||||||
<script src="{% static 'js/htmx.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 %}
|
{% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono">{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %}
|
<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" %}
|
{% include "block/header.html" %}
|
||||||
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
|
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
|
||||||
{% block CONTENT %}{% endblock CONTENT %}
|
{% block CONTENT %}{% endblock CONTENT %}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% 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">
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */
|
hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */
|
||||||
hasImage: false, /* Флаг загрузки пользовательского файла */
|
hasImage: false, /* Флаг загрузки пользовательского файла */
|
||||||
timer: null, /* Ссылка на таймер удержания анимации размышления */
|
timer: null, /* Ссылка на таймер удержания анимации размышления */
|
||||||
|
copied: false, /* Флаг успешного копирования в буфер */
|
||||||
|
|
||||||
/* Метод запуска состояния размышления (8 секунд) */
|
/* Метод запуска состояния размышления (8 секунд) */
|
||||||
startThinking() {
|
startThinking() {
|
||||||
@@ -32,6 +33,29 @@
|
|||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.hasChanges = false;
|
this.hasChanges = false;
|
||||||
}, 8000);
|
}, 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);
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
|
|
||||||
@@ -45,7 +69,9 @@
|
|||||||
@change="hasChanges = true"
|
@change="hasChanges = true"
|
||||||
@input="hasChanges = true"
|
@input="hasChanges = true"
|
||||||
@htmx:before-request="startThinking()"
|
@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">
|
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"
|
<div x-show="isLoading"
|
||||||
@@ -65,6 +91,7 @@
|
|||||||
<label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label>
|
<label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<input type="file" name="image" accept="image/*"
|
<input type="file" name="image" accept="image/*"
|
||||||
|
@change="hasImage = $el.files.length > 0; hasChanges = true"
|
||||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||||
<div
|
<div
|
||||||
class="border-2 border-dashed border-stone-300 dark:border-zinc-700 group-hover:border-emerald-500 rounded-xl p-8 flex flex-col items-center justify-center text-center transition-colors">
|
class="border-2 border-dashed border-stone-300 dark:border-zinc-700 group-hover:border-emerald-500 rounded-xl p-8 flex flex-col items-center justify-center text-center transition-colors">
|
||||||
@@ -410,13 +437,17 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<button
|
<button
|
||||||
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">
|
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">
|
<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>
|
<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>
|
||||||
КОПИРОВАТЬ SVG
|
<span x-text="copied ? 'СКОПИРОВАНО В БУФЕР!' : 'КОПИРОВАТЬ SVG'"></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
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">
|
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>
|
<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>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user