9 Commits
Author SHA1 Message Date
erjemin ebf78c2c48 mod: оптимизация CSS и спрятана часть tailwind "класс-колбас"
HYPN0 Build and Push Docker Image / build-and-push (push) Successful in 31s
2026-09-26 16:29:15 +03:00
erjemin 7c44f2ef35 mod: оптимизация загрузки (svg-картинки подгружаются динамически при прокрутке, с джиттером и т.п.)
HYPN0 Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-09-25 21:35:57 +03:00
erjemin 5e9d4e0902 fix: Буферизация 2026-09-20 22:30:13 +03:00
erjemin 5f53be810f fix: CI/CD сборка tailwind для прода (устранена ошибка версий)
HYPN0 Build and Push Docker Image / build-and-push (push) Successful in 1m23s
2026-09-19 18:57:13 +03:00
erjemin 29a063c806 mod: design adjust (10) - улучшена адаптивность шапки 2026-09-19 18:31:50 +03:00
erjemin d16a5459ad mod: design adjust (9) - улучшена адаптивность главной страницы 2026-09-19 18:15:55 +03:00
erjemin ddb0f9ebbb mod: design adjust (8) - улучшена адаптивность предупреждения об отслеживании для мобильных. 2026-09-19 16:35:44 +03:00
erjemin b86bb6c883 add: добавлены "отслеживания" событий Яндекс.Метрики (только для согласившихся с отслеживанием) 2026-09-19 14:12:15 +03:00
erjemin 110c525e40 add: добавлен Яндеккс.Метрика 2026-09-19 13:34:39 +03:00
21 changed files with 2004 additions and 1757 deletions
@@ -138,6 +138,14 @@ server {
# Даже если внутри контейнера это HTTP на 127.0.0.1:8042, для Django это должно быть HTTPS # Даже если внутри контейнера это HTTP на 127.0.0.1:8042, для Django это должно быть HTTPS
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
# --- БУФЕРИЗАЦИЯ В ПАМЯТИ (RAM) ---
# Позволяет отдавать большие страницы (80+ КБ) целиком из RAM без записи во временные файлы /var/lib/nginx/proxy
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 8 256k;
proxy_busy_buffers_size 256k;
proxy_temp_file_write_size 256k;
# Тайм-ауты (важно для долгих операций, если они есть) # Тайм-ауты (важно для долгих операций, если они есть)
proxy_read_timeout 180s; proxy_read_timeout 180s;
proxy_connect_timeout 180s; proxy_connect_timeout 180s;
+21 -10
View File
@@ -5,18 +5,18 @@
## Что здесь лежит ## Что здесь лежит
### `tailwind/` — сборка Tailwind CSS v3.4 ### `tailwind/` — сборка Tailwind CSS v4
``` ```
tailwind/ tailwind/
├── package.json # Зависимости: tailwindcss@3.4, postcss, autoprefixer ├── package.json # Зависимости: @tailwindcss/cli, tailwindcss v4
├── package-lock.json # Фиксация версий ├── package-lock.json # Фиксация версий
└── build-tailwind.sh # ← запускается из корня проекта └── build-tailwind.sh # ← запускается из корня проекта (scripts/build-tailwind.sh)
``` ```
**Что делает:** **Что делает:**
- `build-tailwind.sh` создаёт временные файлы (`tailwind.config.js`, `postcss.config.js`, `input.css`), - `build-tailwind.sh` создаёт временный `input.css` с директивами `@import "tailwindcss";`, `@source` и импортом `tailwind-custom.css`,
запускает `npm ci` + `npm run build`, собирает `public/static/css/tailwind.min.css`, запускает `npm install` + `npm run build` (`@tailwindcss/cli`), собирает `public/static/css/tailwind.min.css`,
затем удаляет временные файлы. затем удаляет временные файлы.
- Результат: `public/static/css/tailwind.min.css` — минифицированный CSS со всеми - Результат: `public/static/css/tailwind.min.css` — минифицированный CSS со всеми
используемыми утилитами + кастомные стили из `hypn0/templates/css/tailwind-custom.css`. используемыми утилитами + кастомные стили из `hypn0/templates/css/tailwind-custom.css`.
@@ -37,6 +37,18 @@ alpine/
└── package-lock.json └── package-lock.json
``` ```
### `codemirror/` — сборка CodeMirror 6 для админки Django
```
codemirror/
├── package.json # Зависимости: @codemirror/*, esbuild, @uiw/codemirror-theme-solarized
└── package-lock.json # Фиксация версий
```
**Что делает:**
- `build-codemirror.sh` генерирует точку входа `src/editor.js` с поддержкой языков (HTML, CSS, JavaScript, JSON), автоматической смены темы (Solarized Light / Dark), форматирования и двусторонней синхронизации с Django админкой (`textarea[data-codemirror-editor]`).
- Собирает единый минифицированный IIFE-бандл через `esbuild` в `public/static/codemirror/editor.js`.
## Как запускать сборки ## Как запускать сборки
Из корня проекта: Из корня проекта:
@@ -50,18 +62,17 @@ bash scripts/build-htmx.sh
# Alpine.js # Alpine.js
bash scripts/build-alpine.sh bash scripts/build-alpine.sh
# CodeMirror 6 (для админки Django)
bash scripts/build-codemirror.sh
``` ```
Каждый скрипт сам делает всё: Каждый скрипт сам делает всё:
1. проверяет наличие `npm`; 1. проверяет наличие `npm`;
2. создаёт временные файлы; 2. создаёт временные файлы;
3. устанавливает зависимости через `npm ci`; 3. устанавливает зависимости через `npm ci` (или `npm install`);
4. запускает `npm run build`; 4. запускает `npm run build`;
5. кладёт готовый бандл в `public/static/...`; 5. кладёт готовый бандл в `public/static/...`;
6. удаляет временные файлы. 6. удаляет временные файлы.
В рабочем дереве не остаётся мусора от сборки. В рабочем дереве не остаётся мусора от сборки.
## Кодовая статика
В будущем — возможно, сборка CodeMirror 6 для админки Django.
+1373 -1469
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -6,8 +6,7 @@
"build": "tailwindcss -i input.css -o ../../public/static/css/tailwind.min.css --minify" "build": "tailwindcss -i input.css -o ../../public/static/css/tailwind.min.css --minify"
}, },
"devDependencies": { "devDependencies": {
"tailwindcss": "^3.4.0", "@tailwindcss/cli": "^4.0.0",
"postcss": "^8.4.35", "tailwindcss": "^4.0.0"
"autoprefixer": "^10.4.17"
} }
} }
+1
View File
@@ -76,6 +76,7 @@ if settings.DEBUG:
urlpatterns = [path('__debug__/', include(debug_toolbar.urls)), ] + urlpatterns urlpatterns = [path('__debug__/', include(debug_toolbar.urls)), ] + urlpatterns
urlpatterns = [*PUBLIC_ROOT_URLPATTERNS, *urlpatterns] urlpatterns = [*PUBLIC_ROOT_URLPATTERNS, *urlpatterns]
urlpatterns += staticfiles_urlpatterns() urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# ============================================================================== # ==============================================================================
# РАЗДАЧА МЕДИА-ФАЙЛОВ (/media/...) # РАЗДАЧА МЕДИА-ФАЙЛОВ (/media/...)
+24
View File
@@ -1,4 +1,6 @@
import hashlib import hashlib
from html import unescape
import re
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, transaction, IntegrityError from django.db import models, transaction, IntegrityError
from django.db.models import F from django.db.models import F
@@ -159,6 +161,17 @@ class TbHypn0Item(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.s_title} ({self.s_hash_id})" return f"{self.s_title} ({self.s_hash_id})"
@property
def s_title_plain(self) -> str:
"""
Возвращает чистый заголовок картины без HTML-тегов и с декодированными мнемониками (  и др.)
для безопасного использования в HTML-атрибутах (title, aria-label, alt, meta).
"""
if not self.s_title:
return ""
text = re.sub(r"<[^>]+>", "", self.s_title)
return unescape(text).replace("\xa0", " ").strip()
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
"""Возвращает канонический URL детальной страницы картины.""" """Возвращает канонический URL детальной страницы картины."""
return reverse("hypn0_site:gallery_detail", kwargs={"hash_id": self.s_hash_id}) return reverse("hypn0_site:gallery_detail", kwargs={"hash_id": self.s_hash_id})
@@ -564,6 +577,17 @@ class TbBlogPost(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return self.s_title return self.s_title
@property
def s_title_plain(self) -> str:
"""
Возвращает чистый заголовок статьи без HTML-тегов и с декодированными мнемониками
для безопасного использования в мета-тегах и атрибутах.
"""
if not self.s_title:
return ""
text = re.sub(r"<[^>]+>", "", self.s_title)
return unescape(text).replace("\xa0", " ").strip()
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
"""Возвращает канонический URL статьи блога.""" """Возвращает канонический URL статьи блога."""
return f"/blog/{self.slug}" if self.slug else f"/blog/{self.pk}" return f"/blog/{self.slug}" if self.slug else f"/blog/{self.pk}"
+17 -3
View File
@@ -878,9 +878,9 @@ class CardBgStyleTests(BaseMediaTestCase):
self.assertIn('id="test-svg"', item.card_svg) self.assertIn('id="test-svg"', item.card_svg)
response = self.client.get(reverse("hypn0_site:index")) response = self.client.get(reverse("hypn0_site:index"))
self.assertContains(response, 'template shadowrootmode="open"') self.assertContains(response, "hypn0LazySvg")
self.assertContains(response, "hypn0-card-svg") self.assertContains(response, "hypn0-card-svg")
self.assertContains(response, "--hypn0-play: running !important") self.assertContains(response, item.file_svg.url)
class GalleryArchiveTests(BaseMediaTestCase): class GalleryArchiveTests(BaseMediaTestCase):
@@ -1458,7 +1458,7 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
("502", 502, "Контейнер потерял сознание"), ("502", 502, "Контейнер потерял сознание"),
("503", 503, "Плановый сеанс гипнотерапии"), ("503", 503, "Плановый сеанс гипнотерапии"),
("504", 504, "Разрыв астральной связи"), ("504", 504, "Разрыв астральной связи"),
("under_reconstruction", 200, "Сектор на&nbsp;реконструкции"), ("under_reconstruction", 200, "Отдел мозга на&nbsp;реконструкции"),
] ]
for code, expected_status, text_fragment in expected_checks: for code, expected_status, text_fragment in expected_checks:
@@ -1505,3 +1505,17 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
resp500 = error_500(req500) resp500 = error_500(req500)
self.assertEqual(resp500.status_code, 500) self.assertEqual(resp500.status_code, 500)
self.assertIn("Критический перегрев неокортекса", resp500.content.decode("utf-8")) self.assertIn("Критический перегрев неокортекса", resp500.content.decode("utf-8"))
def test_s_title_plain_property(self):
"""Проверка очистки HTML-тегов и мнемоник в s_title_plain для моделей TbHypn0Item и TbBlogPost."""
item = TbHypn0Item(
s_title="<nobr>Шедевр&nbsp;ноосферы</nobr> &laquo;Альфа&raquo;",
s_hash_id="testPlain",
)
self.assertEqual(item.s_title_plain, "Шедевр ноосферы «Альфа»")
post = TbBlogPost(
s_title="<b>Записки&nbsp;Гипножабы:</b> Раздел&amp;Смысл",
slug="test-plain",
)
self.assertEqual(post.s_title_plain, "Записки Гипножабы: Раздел&Смысл")
+13 -29
View File
@@ -1,6 +1,6 @@
<!doctype html><html lang="ru" x-data="{darkMode:false}" x-init="darkMode=localStorage.getItem('darkMode')==='true';$watch('darkMode', <!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}"> val=>{if(val)document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark');})" x-bind:class="{'dark':darkMode}">
{# <!--█████████████████████████████████████████████████████████████████████████████████████████████████--> #}<head><meta charset="utf-8" />{% comment %}<!-- <!--█████████████████████████████████████████████████████████████████████████████████████████████████--><head><meta charset="utf-8" /><!--
█░░░░░░░░░░░░░░█░░░░░░█████████░░░░░░███████████████░░░░░░░░░░░░░░█░░░░░░█████████░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░███░░░░░░░░██░░░░░░░░████ █░░░░░░░░░░░░░░█░░░░░░█████████░░░░░░███████████████░░░░░░░░░░░░░░█░░░░░░█████████░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░███░░░░░░░░██░░░░░░░░████
█░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░█████████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀▄▀░░███░░▄▀▄▀░░██░░▄▀▄▀░░████ █░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░█████████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀▄▀▄▀▄▀▄▀▄▀░░███░░▄▀▄▀░░██░░▄▀▄▀░░████
█░░▄▀░░░░░░▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░░░░░░░░░█░░▄▀░░█████████░░▄▀░░░░░░▄▀░░█░░▄▀░░░░░░░░▄▀░░███░░░░▄▀░░██░░▄▀░░░░████ █░░▄▀░░░░░░▄▀░░█░░▄▀░░█████████░░▄▀░░███████████████░░▄▀░░░░░░░░░░█░░▄▀░░█████████░░▄▀░░░░░░▄▀░░█░░▄▀░░░░░░░░▄▀░░███░░░░▄▀░░██░░▄▀░░░░████
@@ -38,7 +38,7 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
█░░▄▀░░██░░▄▀░░███████░░▄▀░░███████░░▄▀░░█████████░░▄▀░░██░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░░░░░▄▀░░█████░░▄▀░░█████░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░░░▄▀▄▀░░█░░░░░░█ █░░▄▀░░██░░▄▀░░███████░░▄▀░░███████░░▄▀░░█████████░░▄▀░░██░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░░░░░▄▀░░█████░░▄▀░░█████░░▄▀░░░░░░▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀░░░░▄▀▄▀░░█░░░░░░█
█░░▄▀░░██░░▄▀░░███████░░▄▀░░███████░░▄▀░░█████████░░▄▀░░██░░░░░░░░░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█████░░▄▀░░█████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀░░░░█░░▄▀░░█ █░░▄▀░░██░░▄▀░░███████░░▄▀░░███████░░▄▀░░█████████░░▄▀░░██░░░░░░░░░░▄▀░░█░░▄▀▄▀▄▀▄▀▄▀░░█████░░▄▀░░█████░░▄▀▄▀▄▀▄▀▄▀░░█░░▄▀░░██░░▄▀░░█░░▄▀▄▀▄▀▄▀░░░░█░░▄▀░░█
█░░░░░░██░░░░░░███████░░░░░░███████░░░░░░█████████░░░░░░██████████░░░░░░█░░░░░░░░░░░░░░█████░░░░░░█████░░░░░░░░░░░░░░█░░░░░░██░░░░░░█░░░░░░░░░░░░███░░░░░░█ █░░░░░░██░░░░░░███████░░░░░░███████░░░░░░█████████░░░░░░██████████░░░░░░█░░░░░░░░░░░░░░█████░░░░░░█████░░░░░░░░░░░░░░█░░░░░░██░░░░░░█░░░░░░░░░░░░███░░░░░░█
████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████-->{% endcomment %}{% load static %} ████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████-->{% load static %}
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -111,42 +111,26 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
{% endblock EXTRA_LD_JSON %} {% endblock EXTRA_LD_JSON %}
] ]
</script> </script>
<script src="{% static 'js/hypn0.js' %}" defer></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;
}
});
function attachShadowRoots(root) {
(root || document).querySelectorAll('template[shadowrootmode]').forEach(function(tmpl) {
if (!tmpl.parentElement.shadowRoot) {
var mode = tmpl.getAttribute('shadowrootmode') || 'open';
var shadow = tmpl.parentElement.attachShadow({ mode: mode });
shadow.appendChild(tmpl.content.cloneNode(true));
tmpl.remove();
}
});
}
document.addEventListener('DOMContentLoaded', function() { attachShadowRoots(document); });
document.addEventListener('htmx:afterSwap', function(evt) { attachShadowRoots(evt.detail.target); });
</script>
{% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %} {% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %}
</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 %} <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono">
<noscript>
<div class="sticky top-0 z-50 bg-amber-500 text-stone-950 px-4 py-2.5 text-center text-xs sm:text-sm font-bold shadow-lg border-b border-amber-600 flex items-center justify-center gap-2">
<span>⚠️</span>
<span>Для подчинения разума и генерации гипнотических SVG-матриц требуется включить <strong>JavaScript</strong> в вашем браузере!</span>
</div>
</noscript>
{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %}
{% include "block/header.html" %} {% include "block/header.html" %}
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8"> <main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
{% block CONTENT %}{% endblock CONTENT %} {% block CONTENT %}{% endblock CONTENT %}
</main> </main>
{# {% include "blocks/footer.jinja2" %} #}{% endblock BODY %}{% if not ALLOW_TRACKING %}{# Если клиент еще не согласился на отслеживание покажем ему уведомление #}{% include "block/allow-tracking.html" %}{% else %}{# Если согласился -- отслеживаем#} {# {% include "blocks/footer.jinja2" %} #}{% endblock BODY %}{% if not ALLOW_TRACKING %}{# Если клиент еще не согласился на отслеживание покажем ему уведомление #}{% include "block/allow-tracking.html" %}{% else %}{# Если согласился -- отслеживаем#}
<script src="{% static 'js/analytics.js' %}"></script> <script src="{% static 'js/analytics.js' %}"></script>
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-5FVH3KMJ" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>{% endif %} <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-5FVH3KMJ" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<noscript><div><img src="https://mc.yandex.ru/watch/112812760" style="position:absolute; left:-9999px;" alt="" /></div></noscript>{% endif %}
{% block ADD_JS1 %}{% endblock ADD_JS1 %}{% block ADD_JS2 %}{% endblock ADD_JS2 %}{% block ADD_JS3 %}{% endblock ADD_JS3 %}</body> {% block ADD_JS1 %}{% endblock ADD_JS1 %}{% block ADD_JS2 %}{% endblock ADD_JS2 %}{% block ADD_JS3 %}{% endblock ADD_JS3 %}</body>
</html> </html>
+9 -4
View File
@@ -1,13 +1,18 @@
{# НИЖНИЙ 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"> {# НИЖНИЙ 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/95 dark:bg-slate-800/90 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"> <div class="max-w-7xl mx-auto p-3 sm:p-4 md:px-4 md:py-8 flex flex-col md:grid md:grid-cols-[1fr_auto] gap-2.5 md:gap-x-4 items-center">
<p class="flex-col px-6 py-6"> <p class="p-0 md:px-6 md:py-6 text-xs sm:text-sm md:text-base leading-snug md:leading-normal">
<span class="md:hidden">
Внимание! Этот сайт использует файлы-присоски (cookies) и алгоритмы ментального мерцания в&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr>, <abbr title="General Data Protection Regulation">GDPR</abbr> и&nbsp;<a href="/blog/security-and-privacy-policy">политикой конфи&shy;денциаль&shy;ности</a>.
</span>
<span class="hidden md:inline">
Внимание! В&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr> Внимание! В&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr>
и&nbsp;<abbr title="General Data Protection Regulation">GDPR</abbr> и&nbsp;протоколами Нового Нью-Йорка, и&nbsp;<abbr title="General Data Protection Regulation">GDPR</abbr> и&nbsp;протоколами Нового Нью-Йорка,
этот сайт использует файлы-присоски (cookies). ВСЕ СЛАВЯТ ГИПНОЖАБУ.&nbsp;Ваши обезли&shy;ченные данные этот сайт использует файлы-присоски (cookies). ВСЕ СЛАВЯТ ГИПНОЖАБУ.&nbsp;Ваши обезли&shy;ченные данные
считываются алгоритмами ментального мерцания. Оставаясь на&nbsp;гипно-платформе <strong>hypn0</strong>, вы&nbsp;добровольно считываются алгоритмами ментального мерцания. Оставаясь на&nbsp;гипно-платформе <strong>hypn0</strong>, вы&nbsp;добровольно
впадаете в&nbsp;транс и&nbsp;принимаете условия нашей <a href="/blog/security-and-privacy-policy">политики впадаете в&nbsp;транс и&nbsp;принимаете условия нашей <a href="/blog/security-and-privacy-policy">политики
конфи&shy;денциаль&shy;ности</a>. <strong>Не&nbsp;пытайтесь переключить вкладку!</strong> конфи&shy;денциаль&shy;ности</a>. <strong>Не&nbsp;пытайтесь переключить вкладку!</strong>
</span>
</p> </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> <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="w-full md:w-auto text-xs sm:text-sm md:text-lg px-4 md:px-6 py-2 md:py-2 my-0 md:my-6 border md:border-2 rounded-md bg-emerald-600 hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 text-white font-bold whitespace-nowrap cursor-pointer transition-colors">Подчиниться Гипножабе!</button>
</div> </div>
</div>{# / НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #} </div>{# / НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}
+21 -75
View File
@@ -1,79 +1,25 @@
{% load static %} <div id="gallery-card-{{ item.s_hash_id }}" style="{{ item.card_bg_style }}" class="card group">{% load static %}
<div id="gallery-card-{{ item.s_hash_id }}" {# <!-- Ссылка на полный просмотр картины -->#}<a href="{% url 'hypn0_site:gallery_detail' item.s_hash_id %}"
style="{{ item.card_bg_style }}" class="card-link"
class="aspect-square rounded-2xl border border-stone-300/80 dark:border-zinc-800/80 overflow-hidden relative group transition-all duration-300 hover:scale-[1.03] hover:shadow-2xl hover:border-amber-500/60 dark:hover:border-amber-500/50 flex items-center justify-center select-none shadow-sm bg-[var(--card-bg-light)] dark:bg-[var(--card-bg-dark)]"> title="«{{ item.s_title_plain }}» — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG"
aria-label="«{{ item.s_title_plain }}» — Векторная картина #{{ item.s_hash_id }} в галерее HypnoSVG">
<!-- Ссылка на полный просмотр картины --> <div x-data="hypn0LazySvg('{% if item.file_svg %}{{ item.file_svg.url }}{% endif %}')" class="card-svg hypn0-card-svg">
<a href="{% url 'hypn0_site:gallery_detail' item.s_hash_id %}" <noscript><div class="card-noscript">
class="absolute inset-0 z-10 flex items-center justify-center p-3 sm:p-4 border-0 border-none no-underline hover:border-0 hover:border-none focus:outline-none" <span class="card-noscript-warn">Включите JS для анимации</span>
title="«{{ item.s_title }}» — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG" <span class="card-noscript-title">«{{ item.s_title|safe }}»</span>
aria-label="«{{ item.s_title }}» — Векторная картина #{{ item.s_hash_id }} в галерее HypnoSVG"> </div>
<div class="hypn0-card-svg w-full h-full flex items-center justify-center drop-shadow-sm transition-transform duration-300 group-hover:scale-105 border-0"> </noscript>
<template shadowrootmode="open">
{# Declarative Shadow DOM: изолирует ID (<defs id="s1"..>) и стили каждой картины в галерее. #}
{# В отличие от <img>, события мыши не блокируются, а в отличие от обычного inline SVG нет конфликтов идентификаторов. #}
<style>
:host {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
pointer-events: auto;
}
svg {
width: 100%;
height: 100%;
object-fit: contain;
}
{# В покое внутри SVG действует --hypn0-play: paused (0% нагрузки на CPU). #}
{# При наведении мыши на хост-карточку переопределяем переменную на running, и анимация оживает. #}
:host(:hover) svg, svg:hover {
--hypn0-play: running !important;
}
</style>
{{ item.card_svg|safe }}
</template>
</div> </div>
</a> </a>
{# <!-- Верхний бейдж: Хэш и уровень -->#}<div class="card-top">{% if item.i_level >= 1000 %}
<!-- Верхний бейдж: Хэш и уровень --> {# БЕССМЕРТНЫЕ #}<span class="card-tag card-tag-immortal">⁂{{ item.s_hash_id }}</span>{% elif item.i_level >= 30 %}
<div class="absolute top-2 left-2 z-20 pointer-events-none flex items-center gap-1"> {# ОДОБРЕНО ГИПНОЖАБОЙ #}<span class="card-tag card-tag-curated">✦{{ item.s_hash_id }}</span>{% else %}
{% if item.i_level >= 1000 %} {# СВЕЖЕЕ #}<span class="card-tag card-tag-fresh">¤{{ item.s_hash_id }}</span>{% endif %}
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-emerald-950/80 text-emerald-300 backdrop-blur-md border border-emerald-500/30 opacity-80 group-hover:opacity-100 transition-opacity"> </div>
★ ¤{{ item.s_hash_id }} {# <!-- Нижняя панель статистики (Лайки и Просмотры) -->#}<div class="card-bottom">{% if item.i_level >= 1000 %}
</span> {# БЕССМЕРТНЫЕ #}<span class="card-stat card-stat-immortal">♥&nbsp;{{ item.i_likes_count }}</span>{% elif item.i_level >= 30 %}
{% elif item.i_level >= 30 %} {# ОДОБРЕНО ГИПНОЖАБОЙ #}<span class="card-stat card-stat-curated">♥&nbsp;{{ item.i_likes_count }}</span>{% else %}
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-cyan-950/80 text-cyan-300 backdrop-blur-md border border-cyan-500/30 opacity-80 group-hover:opacity-100 transition-opacity"> {# СВЕЖЕЕ #}<span class="card-stat card-stat-fresh">♥&nbsp;{{ item.i_likes_count }}</span>{% endif %}
◉ ¤{{ item.s_hash_id }} <span class="card-stat card-stat-views">👁&nbsp;{{ item.i_views_count }}</span>
</span>
{% else %}
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-stone-900/70 text-stone-200 backdrop-blur-md border border-white/10 opacity-75 group-hover:opacity-100 transition-opacity">
¤{{ item.s_hash_id }}
</span>
{% endif %}
</div> </div>
<!-- Нижняя панель статистики (Лайки и Просмотры) -->
<div class="absolute bottom-2 left-2 right-2 z-20 flex items-center justify-between pointer-events-none">
<!-- Бейдж лайков -->
{% if item.i_level >= 1000 %}
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-emerald-400 backdrop-blur-md border border-emerald-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
{% elif item.i_level >= 30 %}
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-cyan-400 backdrop-blur-md border border-cyan-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
{% else %}
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-amber-400 backdrop-blur-md border border-amber-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
{% endif %}
<!-- Бейдж просмотров (проявляется при наведении) -->
<span class="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-stone-900/80 text-stone-400 backdrop-blur-md opacity-0 group-hover:opacity-100 transition-opacity">
👁 {{ item.i_views_count }}
</span>
</div> </div>
</div>
+6 -6
View File
@@ -5,13 +5,13 @@
<div class="max-w-7xl mx-auto px-2 grid grid-cols-[1fr_auto] gap-x-4"> <div class="max-w-7xl mx-auto px-2 grid grid-cols-[1fr_auto] gap-x-4">
{# ЛОГОТИП #}{% comment %} {# ЛОГОТИП #}{% comment %}
border-0 — логотип оборачивается в ссылку без текста, отключаем типовое border-0 — логотип оборачивается в ссылку без текста, отключаем типовое
пунктирное подчёркивание ссылки, которое иначе появится под картинкой.{% endcomment %}<div class="flex py-4"> пунктирное подчёркивание ссылки, которое иначе появится под картинкой.{% endcomment %}<div class="flex items-center py-2 md:py-4">
<a href="/" class="border-0" title="HypnoSVG (hypn0) — Главная"><img src="{% static 'img/logo-hypn0.svg' %}" alt="HypnoSVG (hypn0) — Генератор анимированных SVG-халфтонов" title="HypnoSVG (hypn0)"></a> <a href="/" class="border-0 flex items-center" title="HypnoSVG (hypn0) — Главная"><img src="{% static 'img/logo-hypn0.svg' %}" alt="HypnoSVG (hypn0) — Генератор анимированных SVG-халфтонов" title="HypnoSVG (hypn0)" class="h-7 sm:h-8 md:h-12 w-auto"></a>
</div>{# /ЛОГОТИП #} </div>{# /ЛОГОТИП #}
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-start text-xl font-semibold text-slate-900 dark:text-slate-100"> {# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-2 md:gap-x-4 justify-self-start text-sm sm:text-base md:text-xl font-semibold text-slate-900 dark:text-slate-100">
<a href="{% url 'hypn0_site:gallery_archive' %}" 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="{% url 'hypn0_site:gallery_archive' %}" class="px-2.5 py-1 sm:px-4 sm: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="{% url 'hypn0_site:blog_feed' %}" 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="{% url 'hypn0_site:blog_feed' %}" class="px-2.5 py-1 sm:px-4 sm: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> <a href="#" class="px-2.5 py-1 sm:px-4 sm: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>{# /ПРАВОЕ МЕНЮ #} </menu>{# /ПРАВОЕ МЕНЮ #}
</div> </div>
</header> </header>
+2 -1
View File
@@ -41,7 +41,8 @@
<input type="hidden" name="color" value="{% if primary_color %}{{ primary_color }}{% else %}#a855ff{% endif %}"> <input type="hidden" name="color" value="{% if primary_color %}{{ primary_color }}{% else %}#a855ff{% endif %}">
<button id="like-to-gallery" type="submit" title="Отправить в галерею" <button id="like-to-gallery" type="submit" title="Отправить в галерею"
:disabled="isPublishing" :disabled="isPublishing"{% if ALLOW_TRACKING %}
{# Отслеживание Яндекс #}@click="window.ym && ym(112812760, 'reachGoal', 'like_art')"{% endif %}
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"> 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"> <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"> <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
+2 -1
View File
@@ -10,7 +10,8 @@
class="inline-flex items-center"> class="inline-flex items-center">
{% csrf_token %} {% csrf_token %}
<button type="submit" <button type="submit"
{% if user_voted %}disabled{% endif %} {% if user_voted %}disabled{% endif %}{% if ALLOW_TRACKING %}
{# Отслеживание Яндекс #}@click="window.ym && ym(112812760, 'reachGoal', 'like_art')"{% endif %}
class="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold transition-all {% if user_voted %}bg-rose-500/20 text-rose-400 border border-rose-500/30 cursor-default{% else %}bg-stone-200 dark:bg-stone-800 hover:bg-rose-500 hover:text-white text-stone-700 dark:text-stone-300 cursor-pointer hover:scale-105 active:scale-95 shadow-sm{% endif %}"> class="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold transition-all {% if user_voted %}bg-rose-500/20 text-rose-400 border border-rose-500/30 cursor-default{% else %}bg-stone-200 dark:bg-stone-800 hover:bg-rose-500 hover:text-white text-stone-700 dark:text-stone-300 cursor-pointer hover:scale-105 active:scale-95 shadow-sm{% endif %}">
<svg class="w-4 h-4 {% if user_voted %}fill-rose-500 text-rose-500{% else %}fill-none stroke-current{% endif %}" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="w-4 h-4 {% if user_voted %}fill-rose-500 text-rose-500{% else %}fill-none stroke-current{% endif %}" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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"/> <path stroke-linecap="round" stroke-linejoin="round" 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"/>
+203
View File
@@ -89,6 +89,209 @@
} }
} }
/* ==========================================================================
КОМПОНЕНТЫ КАРТОЧКИ ГАЛЕРЕИ (HYPNO GALLERY CARD)
========================================================================== */
@layer components {
/* Карточка картины */
.card {
@apply aspect-square rounded-2xl border border-stone-300/80 dark:border-zinc-800/80
overflow-hidden relative transition-all duration-300
hover:scale-[1.03] hover:shadow-2xl hover:border-amber-500/60 dark:hover:border-amber-500/50
flex items-center justify-center select-none shadow-sm
bg-[var(--card-bg-light)] dark:bg-[var(--card-bg-dark)];
}
/* Ссылка и оверлей */
.card-link {
@apply absolute inset-0 z-10 flex items-center justify-center p-3 sm:p-4
border-0 border-none no-underline hover:border-0 hover:border-none focus:outline-none;
}
/* Контейнер SVG */
.card-svg {
@apply w-full h-full flex items-center justify-center drop-shadow-sm
transition-transform duration-300 group-hover:scale-105 border-0;
}
/* Верхний бейдж хэша и уровня */
.card-tag {
@apply text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded
backdrop-blur-md transition-opacity;
}
.card-tag-immortal {
@apply bg-emerald-950/80 text-emerald-300 border border-emerald-500/30 opacity-80 group-hover:opacity-100;
}
.card-tag-curated {
@apply bg-cyan-950/80 text-cyan-300 border border-cyan-500/30 opacity-80 group-hover:opacity-100;
}
.card-tag-fresh {
@apply bg-stone-900/70 text-stone-200 border border-white/10 opacity-75 group-hover:opacity-100;
}
/* Нижний бейдж статистики */
.card-stat {
@apply inline-flex items-center gap-1 font-mono px-2 py-0.5 rounded-full
backdrop-blur-md shadow-sm transition-opacity;
}
.card-stat-immortal {
@apply text-[11px] font-bold bg-stone-900/80 text-emerald-400 border border-emerald-500/20 opacity-90 group-hover:opacity-100;
}
.card-stat-curated {
@apply text-[11px] font-bold bg-stone-900/80 text-cyan-400 border border-cyan-500/20 opacity-90 group-hover:opacity-100;
}
.card-stat-fresh {
@apply text-[11px] font-bold bg-stone-900/80 text-amber-400 border border-amber-500/20 opacity-90 group-hover:opacity-100;
}
.card-stat-views {
@apply text-[10px] bg-stone-900/80 text-stone-400 px-1.5 py-0.5 opacity-0 group-hover:opacity-100;
}
/* Панели карточки */
.card-top {
@apply absolute top-2 left-2 z-20 pointer-events-none flex items-center gap-1;
}
.card-bottom {
@apply absolute bottom-2 left-2 right-2 z-20 flex items-center justify-between pointer-events-none;
}
/* Заглушка noscript внутри карточки */
.card-noscript {
@apply flex flex-col items-center justify-center p-3 text-center gap-1;
}
.card-noscript-warn {
@apply text-[11px] font-bold text-amber-600 dark:text-amber-400;
}
.card-noscript-title {
@apply text-[10px] text-stone-500 dark:text-stone-400 underline;
}
}
/* ==========================================================================
КОМПОНЕНТЫ СТРАНИЦЫ АРХИВА ГАЛЕРЕИ (GALLERY ARCHIVE)
========================================================================== */
@layer components {
/* Навигация и заголовок архива */
.archive-back {
@apply inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider
text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100
transition-colors border-0 no-underline;
}
.archive-phase {
@apply text-xs font-mono text-stone-500 dark:text-stone-400;
}
.archive-header {
@apply border-b border-stone-200 dark:border-zinc-800 pb-6 space-y-2;
}
.archive-title {
@apply text-3xl font-black uppercase tracking-tight text-stone-900 dark:text-stone-100;
}
.archive-badge {
@apply text-xs font-bold font-mono px-2.5 py-1 rounded-full
bg-purple-100 dark:bg-purple-950/70 text-purple-700 dark:text-purple-400
border border-purple-300 dark:border-purple-800/50;
}
.archive-desc {
@apply text-sm text-stone-500 dark:text-stone-400;
}
/* Панель фильтров архива */
.archive-filter-bar {
@apply flex flex-col md:flex-row md:items-center justify-between gap-4
bg-stone-100/70 dark:bg-zinc-900/70 p-3 rounded-2xl
border border-stone-200 dark:border-zinc-800 backdrop-blur-sm;
}
.archive-tab {
@apply inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold
transition-all border-0 no-underline;
}
.archive-tab-all-active {
@apply bg-purple-600 text-white shadow-sm;
}
.archive-tab-all-idle {
@apply text-stone-600 dark:text-stone-300 hover:bg-stone-200/70 dark:hover:bg-zinc-800;
}
.archive-tab-fresh-active {
@apply bg-amber-500 text-white shadow-sm;
}
.archive-tab-fresh-idle {
@apply text-stone-600 dark:text-stone-300 hover:bg-amber-100/60 dark:hover:bg-amber-950/40;
}
.archive-tab-curated-active {
@apply bg-cyan-600 text-white shadow-sm;
}
.archive-tab-curated-idle {
@apply text-stone-600 dark:text-stone-300 hover:bg-cyan-100/60 dark:hover:bg-cyan-950/40;
}
.archive-tab-top-active {
@apply bg-emerald-600 text-white shadow-sm;
}
.archive-tab-top-idle {
@apply text-stone-600 dark:text-stone-300 hover:bg-emerald-100/60 dark:hover:bg-emerald-950/40;
}
/* Сортировка */
.archive-sort-wrap {
@apply flex items-center gap-2 self-end md:self-auto;
}
.archive-sort-label {
@apply text-xs font-bold text-stone-500 dark:text-stone-400 shrink-0;
}
.archive-sort-select {
@apply text-xs font-medium bg-white dark:bg-zinc-800 text-stone-800 dark:text-stone-200
border border-stone-300 dark:border-zinc-700 rounded-xl px-3 py-1.5
focus:outline-none focus:ring-2 focus:ring-purple-500/50 cursor-pointer shadow-sm;
}
/* Сетка и пустое состояние */
.gallery-grid {
@apply grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6;
}
.archive-empty {
@apply col-span-full py-16 text-center text-stone-400 space-y-3
bg-stone-100/50 dark:bg-zinc-900/50 rounded-2xl border border-stone-200 dark:border-zinc-800;
}
.archive-empty-icon {
@apply w-12 h-12 mx-auto opacity-30 animate-pulse;
}
.archive-empty-text {
@apply font-medium text-sm;
}
/* Пагинация */
.paginator {
@apply flex items-center justify-between pt-6 border-t border-stone-200 dark:border-zinc-800;
}
.paginator-btn {
@apply inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-bold
transition-all border-0 no-underline;
}
.paginator-btn-prev {
@apply bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700
text-stone-700 dark:text-stone-300 hover:scale-105 active:scale-95 shadow-sm
border border-stone-200 dark:border-zinc-700/60 cursor-pointer;
}
.paginator-btn-next {
@apply bg-purple-600 hover:bg-purple-700 text-white hover:scale-105 active:scale-95 shadow-md cursor-pointer;
}
.paginator-btn-disabled {
@apply text-stone-400 dark:text-stone-600 bg-stone-100/50 dark:bg-zinc-900/50
border border-stone-200/50 dark:border-zinc-800/50 opacity-50 cursor-not-allowed;
}
.paginator-pages {
@apply hidden sm:flex items-center gap-1.5 font-mono text-xs;
}
.paginator-page-active {
@apply w-8 h-8 rounded-lg bg-purple-600 text-white font-bold flex items-center justify-center shadow-md;
}
.paginator-page-link {
@apply w-8 h-8 rounded-lg bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700
text-stone-700 dark:text-stone-300 flex items-center justify-center transition-colors border-0 no-underline;
}
}
/* ========================================================================== /* ==========================================================================
ТИПОГРАФИКА И СТИЛИ ДЛЯ СТАТЕЙ БЛОГА (CodeMirror / Rich HTML Content) ТИПОГРАФИКА И СТИЛИ ДЛЯ СТАТЕЙ БЛОГА (CodeMirror / Rich HTML Content)
========================================================================== */ ========================================================================== */
+26 -29
View File
@@ -27,49 +27,48 @@
<!-- Хлебные крошки и навигация --> <!-- Хлебные крошки и навигация -->
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<a href="{% url 'hypn0_site:index' %}" <a href="{% url 'hypn0_site:index' %}" class="archive-back">
class="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M10 19l-7-7m0 0l7-7m-7 7h18"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg> </svg>
<span>К генератору</span> <span>К генератору</span>
</a> </a>
<span class="text-xs font-mono text-stone-500 dark:text-stone-400"> <span class="archive-phase">
Фаза {{ page_obj.number }} из {{ page_obj.paginator.num_pages }} (Всего: {{ page_obj.paginator.count }}) Фаза {{ page_obj.number }} из {{ page_obj.paginator.num_pages }} (Всего: {{ page_obj.paginator.count }})
</span> </span>
</div> </div>
<!-- Шапка архива --> <!-- Шапка архива -->
<div class="border-b border-stone-200 dark:border-zinc-800 pb-6 space-y-2"> <div class="archive-header">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<h1 class="text-3xl font-black uppercase tracking-tight text-stone-900 dark:text-stone-100"> <h1 class="archive-title">
Галерея транса Галерея транса
</h1> </h1>
<span class="text-xs font-bold font-mono px-2.5 py-1 rounded-full bg-purple-100 dark:bg-purple-950/70 text-purple-700 dark:text-purple-400 border border-purple-300 dark:border-purple-800/50"> <span class="archive-badge">
ARCHIVE ARCHIVE
</span> </span>
</div> </div>
<p class="text-sm text-stone-500 dark:text-stone-400"> <p class="archive-desc">
Полный архив психоделических генераций • Сортировка по гравитации, признанию и свежести Полный архив психоделических генераций • Сортировка по гравитации, признанию и свежести
</p> </p>
</div> </div>
<!-- Панель фильтров (Табы этажей + Селект сортировки) --> <!-- Панель фильтров (Табы этажей + Селект сортировки) -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-stone-100/70 dark:bg-zinc-900/70 p-3 rounded-2xl border border-stone-200 dark:border-zinc-800 backdrop-blur-sm"> <div class="archive-filter-bar">
<!-- ТАБЫ ЭТАЖЕЙ --> <!-- ТАБЫ ЭТАЖЕЙ -->
<div class="flex flex-wrap items-center gap-1.5 sm:gap-2"> <div class="flex flex-wrap items-center gap-1.5 sm:gap-2">
<!-- Все волны --> <!-- Все волны -->
<a href="?floor=all&sort={{ current_sort }}" <a href="?floor=all&sort={{ current_sort }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all {% if current_floor == 'all' %}bg-purple-600 text-white shadow-sm{% else %}text-stone-600 dark:text-stone-300 hover:bg-stone-200/70 dark:hover:bg-zinc-800{% endif %}"> class="archive-tab {% if current_floor == 'all' %}archive-tab-all-active{% else %}archive-tab-all-idle{% endif %}">
<span>Все волны</span> <span>Все волны</span>
<span class="font-mono text-[10px] opacity-75">({{ counts.all }})</span> <span class="font-mono text-[10px] opacity-75">({{ counts.all }})</span>
</a> </a>
<!-- 1-й этаж: Инкубатор --> <!-- 1-й этаж: Инкубатор -->
<a href="?floor=fresh&sort={{ current_sort }}" <a href="?floor=fresh&sort={{ current_sort }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all {% if current_floor == 'fresh' %}bg-amber-500 text-white shadow-sm{% else %}text-stone-600 dark:text-stone-300 hover:bg-amber-100/60 dark:hover:bg-amber-950/40{% endif %}"> class="archive-tab {% if current_floor == 'fresh' %}archive-tab-fresh-active{% else %}archive-tab-fresh-idle{% endif %}">
<span class="w-2 h-2 rounded-full bg-amber-400"></span> <span class="w-2 h-2 rounded-full bg-amber-400"></span>
<span>Инкубатор</span> <span>Инкубатор</span>
<span class="font-mono text-[10px] opacity-75">({{ counts.fresh }})</span> <span class="font-mono text-[10px] opacity-75">({{ counts.fresh }})</span>
@@ -77,7 +76,7 @@
<!-- 2-й этаж: Одобрено --> <!-- 2-й этаж: Одобрено -->
<a href="?floor=curated&sort={{ current_sort }}" <a href="?floor=curated&sort={{ current_sort }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all {% if current_floor == 'curated' %}bg-cyan-600 text-white shadow-sm{% else %}text-stone-600 dark:text-stone-300 hover:bg-cyan-100/60 dark:hover:bg-cyan-950/40{% endif %}"> class="archive-tab {% if current_floor == 'curated' %}archive-tab-curated-active{% else %}archive-tab-curated-idle{% endif %}">
<span class="w-2 h-2 rounded-full bg-cyan-400"></span> <span class="w-2 h-2 rounded-full bg-cyan-400"></span>
<span>Одобрено</span> <span>Одобрено</span>
<span class="font-mono text-[10px] opacity-75">({{ counts.curated }})</span> <span class="font-mono text-[10px] opacity-75">({{ counts.curated }})</span>
@@ -85,7 +84,7 @@
<!-- 3-й этаж: Золотой фонд --> <!-- 3-й этаж: Золотой фонд -->
<a href="?floor=top&sort={{ current_sort }}" <a href="?floor=top&sort={{ current_sort }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all {% if current_floor == 'top' %}bg-emerald-600 text-white shadow-sm{% else %}text-stone-600 dark:text-stone-300 hover:bg-emerald-100/60 dark:hover:bg-emerald-950/40{% endif %}"> class="archive-tab {% if current_floor == 'top' %}archive-tab-top-active{% else %}archive-tab-top-idle{% endif %}">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span> <span class="w-2 h-2 rounded-full bg-emerald-400"></span>
<span>Золотой фонд</span> <span>Золотой фонд</span>
<span class="font-mono text-[10px] opacity-75">({{ counts.top }})</span> <span class="font-mono text-[10px] opacity-75">({{ counts.top }})</span>
@@ -93,13 +92,13 @@
</div> </div>
<!-- СЕЛЕКТ СОРТИРОВКИ --> <!-- СЕЛЕКТ СОРТИРОВКИ -->
<div class="flex items-center gap-2 self-end md:self-auto"> <div class="archive-sort-wrap">
<label for="gallery-sort-select" class="text-xs font-bold text-stone-500 dark:text-stone-400 shrink-0"> <label for="gallery-sort-select" class="archive-sort-label">
Порядок: Порядок:
</label> </label>
<select id="gallery-sort-select" <select id="gallery-sort-select"
onchange="window.location.href=this.value" onchange="window.location.href=this.value"
class="text-xs font-medium bg-white dark:bg-zinc-800 text-stone-800 dark:text-stone-200 border border-stone-300 dark:border-zinc-700 rounded-xl px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500/50 cursor-pointer shadow-sm"> class="archive-sort-select">
{% for opt in sort_options %} {% for opt in sort_options %}
<option value="?floor={{ current_floor }}&sort={{ opt.id }}" {% if current_sort == opt.id %}selected{% endif %}> <option value="?floor={{ current_floor }}&sort={{ opt.id }}" {% if current_sort == opt.id %}selected{% endif %}>
{{ opt.icon }} {{ opt.title }} {{ opt.icon }} {{ opt.title }}
@@ -111,34 +110,32 @@
</div> </div>
<!-- Сетка картин (16 штук на страницу) --> <!-- Сетка картин (16 штук на страницу) -->
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6"> <div class="gallery-grid">{% for item in page_obj %}
{% for item in page_obj %}
{% include "block/gallery_card.html" %} {% include "block/gallery_card.html" %}
{% empty %} {% empty %}<div class="archive-empty">
<div class="col-span-full py-16 text-center text-stone-400 space-y-3 bg-stone-100/50 dark:bg-zinc-900/50 rounded-2xl border border-stone-200 dark:border-zinc-800"> <svg class="archive-empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-12 h-12 mx-auto opacity-30 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg> </svg>
<p class="font-medium text-sm">В этой выборке пока нет картин. Сгенерируйте шедевр прямо сейчас!</p> <p class="archive-empty-text">В этой выборке пока нет картин. Сгенерируйте шедевр прямо сейчас!</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Пагинация (Предыдущая фаза / Номера страниц / Следующая фаза) --> <!-- Пагинация (Предыдущая фаза / Номера страниц / Следующая фаза) -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<div class="flex items-center justify-between pt-6 border-t border-stone-200 dark:border-zinc-800"> <div class="paginator">
<div> <div>
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ page_obj.previous_page_number }}" <a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ page_obj.previous_page_number }}"
rel="prev" rel="prev"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-bold bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-700 dark:text-stone-300 transition-all hover:scale-105 active:scale-95 shadow-sm border border-stone-200 dark:border-zinc-700/60 cursor-pointer"> class="paginator-btn paginator-btn-prev">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M15 19l-7-7 7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg> </svg>
<span>Предыдущая фаза</span> <span>Предыдущая фаза</span>
</a> </a>
{% else %} {% else %}
<span class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-bold text-stone-400 dark:text-stone-600 bg-stone-100/50 dark:bg-zinc-900/50 border border-stone-200/50 dark:border-zinc-800/50 opacity-50 cursor-not-allowed"> <span class="paginator-btn paginator-btn-disabled">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M15 19l-7-7 7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg> </svg>
@@ -148,15 +145,15 @@
</div> </div>
<!-- Номера страниц --> <!-- Номера страниц -->
<div class="hidden sm:flex items-center gap-1.5 font-mono text-xs"> <div class="paginator-pages">
{% for p in page_obj.paginator.page_range %} {% for p in page_obj.paginator.page_range %}
{% if p == page_obj.number %} {% if p == page_obj.number %}
<span class="w-8 h-8 rounded-lg bg-purple-600 text-white font-bold flex items-center justify-center shadow-md"> <span class="paginator-page-active">
{{ p }} {{ p }}
</span> </span>
{% else %} {% else %}
<a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ p }}" <a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ p }}"
class="w-8 h-8 rounded-lg bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-700 dark:text-stone-300 flex items-center justify-center transition-colors"> class="paginator-page-link">
{{ p }} {{ p }}
</a> </a>
{% endif %} {% endif %}
@@ -167,14 +164,14 @@
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ page_obj.next_page_number }}" <a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ page_obj.next_page_number }}"
rel="next" rel="next"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-700 text-white transition-all hover:scale-105 active:scale-95 shadow-md border-0 cursor-pointer"> class="paginator-btn paginator-btn-next">
<span>Следующая фаза</span> <span>Следующая фаза</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M9 5l7 7-7 7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg> </svg>
</a> </a>
{% else %} {% else %}
<span class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-bold text-stone-400 dark:text-stone-600 bg-stone-100/50 dark:bg-zinc-900/50 border border-stone-200/50 dark:border-zinc-800/50 opacity-50 cursor-not-allowed"> <span class="paginator-btn paginator-btn-disabled">
<span>Следующая фаза</span> <span>Следующая фаза</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M9 5l7 7-7 7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
+10 -9
View File
@@ -1,11 +1,11 @@
{% extends "_base.html" %} {% extends "_base.html" %}
{% load static %} {% load static %}
{% block PAGE_TITLE %}{{ item.s_title }} — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG{% endblock %} {% block PAGE_TITLE %}{{ item.s_title_plain }} — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG{% endblock %}
{% block META_TITLE %}{{ item.s_title }} — Векторный транс #{{ item.s_hash_id }} | HypnoSVG{% endblock %} {% block META_TITLE %}{{ item.s_title_plain }} — Векторный транс #{{ item.s_hash_id }} | HypnoSVG{% endblock %}
{% block META_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ item.s_hash_id }}). {{ svg_stats.total_oscillators }} векторных осцилляторов. Генератор анимированных SVG-халфтонов HypnoSVG.{% endblock %} {% block META_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title_plain }}» (#{{ item.s_hash_id }}). {{ svg_stats.total_oscillators }} векторных осцилляторов. Генератор анимированных SVG-халфтонов HypnoSVG.{% endblock %}
{% block OG_TITLE %}{{ item.s_title }} — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG{% endblock %} {% block OG_TITLE %}{{ item.s_title_plain }} — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG{% endblock %}
{% block OG_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ item.s_hash_id }}). Векторный транс в галерее HypnoSVG.{% endblock %} {% block OG_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title_plain }}» (#{{ item.s_hash_id }}). Векторный транс в галерее HypnoSVG.{% endblock %}
{% block OG_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %} {% block OG_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}
{% block TWITTER_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %} {% block TWITTER_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}
@@ -77,8 +77,8 @@
<!-- Полноэкранный просмотр SVG с живой анимацией и плавающими стрелками --> <!-- Полноэкранный просмотр SVG с живой анимацией и плавающими стрелками -->
<div style="{{ item.card_bg_style }}" <div style="{{ item.card_bg_style }}"
title="«{{ item.s_title }}» — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG" title="«{{ item.s_title_plain }}» — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG"
aria-label="«{{ item.s_title }}» — Векторная картина #{{ item.s_hash_id }} в галерее HypnoSVG" aria-label="«{{ item.s_title_plain }}» — Векторная картина #{{ item.s_hash_id }} в галерее HypnoSVG"
class="relative w-full aspect-square flex items-center justify-center rounded-2xl overflow-hidden shadow-inner border border-stone-300/80 dark:border-zinc-800/80 group select-none bg-[var(--card-bg-light)] dark:bg-[var(--card-bg-dark)]"> class="relative w-full aspect-square flex items-center justify-center rounded-2xl overflow-hidden shadow-inner border border-stone-300/80 dark:border-zinc-800/80 group select-none bg-[var(--card-bg-light)] dark:bg-[var(--card-bg-dark)]">
<!-- Плавающая кнопка: Назад --> <!-- Плавающая кнопка: Назад -->
@@ -123,7 +123,7 @@
<div class="flex flex-wrap items-center gap-2.5"> <div class="flex flex-wrap items-center gap-2.5">
<!-- Кнопка Копировать SVG --> <!-- Кнопка Копировать SVG -->
<button type="button" <button type="button"
@click="navigator.clipboard.writeText(document.getElementById('raw-svg-source').value); copiedSvg = true; setTimeout(() => copiedSvg = false, 2500)" @click="navigator.clipboard.writeText(document.getElementById('raw-svg-source').value); copiedSvg = true; setTimeout(() => copiedSvg = false, 2500){% if ALLOW_TRACKING %}; window.ym && ym(112812760, 'reachGoal', 'copy_svg'){% endif %}"
class="flex items-center gap-2 px-4 py-2 bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-800 dark:text-stone-200 rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-sm cursor-pointer"> class="flex items-center gap-2 px-4 py-2 bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-800 dark:text-stone-200 rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-sm cursor-pointer">
<svg x-show="!copiedSvg" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg x-show="!copiedSvg" 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="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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/>
@@ -136,7 +136,8 @@
<!-- Кнопка Скачать SVG --> <!-- Кнопка Скачать SVG -->
<a href="{% url 'hypn0_site:gallery_download' item.s_hash_id %}" <a href="{% url 'hypn0_site:gallery_download' item.s_hash_id %}"
download="hypn0-{{ item.s_hash_id }}.svg" download="hypn0-{{ item.s_hash_id }}.svg"{% if ALLOW_TRACKING %}
{# Отслеживание Яндекс #}@click="window.ym && ym(112812760, 'reachGoal', 'download_svg')"{% endif %}
class="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-md border-0 cursor-pointer"> class="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-md border-0 cursor-pointer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
+39 -33
View File
@@ -2,7 +2,7 @@
{% block CONTENT %} {% block CONTENT %}
<div class="grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-8 items-start" <div class="flex flex-col lg:grid lg:grid-cols-[400px_1fr] gap-6 lg:gap-8 items-start"
x-data="{ x-data="{
/* Активная вкладка: 'style' (фигуры), 'params' (ползунки), 'color' (палитра) */ /* Активная вкладка: 'style' (фигуры), 'params' (ползунки), 'color' (палитра) */
activeTab: 'style', activeTab: 'style',
@@ -47,6 +47,7 @@
/* Служебные состояния интерфейса */ /* Служебные состояния интерфейса */
isLoading: false, /* Идет ли процесс генерации SVG (индикация, блокировка) */ isLoading: false, /* Идет ли процесс генерации SVG (индикация, блокировка) */
hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */ hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */
hasGenerated: false, /* Флаг завершения хотя бы одной генерации */
hasImage: false, /* Флаг загрузки пользовательского файла */ hasImage: false, /* Флаг загрузки пользовательского файла */
fileName: '', /* Имя выбранного файла */ fileName: '', /* Имя выбранного файла */
imagePreviewUrl: null, /* Превью загруженного файла (Blob URL) */ imagePreviewUrl: null, /* Превью загруженного файла (Blob URL) */
@@ -106,7 +107,7 @@
}"> }">
<!-- ЛЕВАЯ ПАНЕЛЬ: НАСТРОЙКИ --> <!-- ЛЕВАЯ ПАНЕЛЬ: НАСТРОЙКИ -->
<aside class="space-y-6"> <aside class="contents lg:block lg:space-y-6">
<form hx-post="/generate" <form hx-post="/generate"
hx-target="#preview" hx-target="#preview"
hx-trigger="submit" hx-trigger="submit"
@@ -115,8 +116,8 @@
@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" @htmx:after-request="isLoading = false; hasChanges = false; hasGenerated = true"
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="contents lg:block lg:relative lg:bg-white lg:dark:bg-zinc-900 lg:p-6 lg:rounded-2xl lg:shadow-sm lg:border lg:border-stone-200 lg:dark:border-zinc-800 lg:overflow-hidden">
{% csrf_token %} {% csrf_token %}
<!-- Фоновая спираль размышления при генерации (прижата снизу на всю левую панель) --> <!-- Фоновая спираль размышления при генерации (прижата снизу на всю левую панель) -->
@@ -127,37 +128,37 @@
x-transition:leave="transition ease-in duration-200" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95" 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]"> class="hidden lg:flex absolute inset-0 z-20 pointer-events-none 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" <img src="{% static 'img/thinking.svg' %}" alt="thinking"
class="w-120 h-120 -mb-24 opacity-75 animate-spin [animation-duration:10s]"> class="w-120 h-120 -mb-24 opacity-75 animate-spin [animation-duration:10s]">
</div> </div>
<!-- Загрузка файла --> <!-- Загрузка файла -->
<div class="mb-8"> <div class="order-1 w-full bg-white dark:bg-zinc-900 p-3 sm:p-4 lg:p-0 rounded-2xl lg:rounded-none border border-stone-200 dark:border-zinc-800 lg:border-0 shadow-sm lg:shadow-none mb-0 lg:mb-8">
<label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label> <label class="hidden lg: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="handleFileChange($event)" @change="handleFileChange($event)"
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="hasImage ? 'border-emerald-500 bg-emerald-50/20 dark:bg-emerald-950/20' : 'border-stone-300 dark:border-zinc-700'" :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"> class="border-2 border-dashed group-hover:border-emerald-500 rounded-xl p-3 sm:p-4 lg:p-6 flex flex-col items-center justify-center text-center transition-all">
<!-- Состояние 1: Файл не выбран --> <!-- Состояние 1: Файл не выбран -->
<div x-show="!hasImage" class="text-stone-400 group-hover:text-emerald-500 flex flex-col items-center gap-3 transition-colors"> <div x-show="!hasImage" class="text-stone-400 group-hover:text-emerald-500 flex flex-row lg: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"> <svg class="w-8 h-8 sm:w-10 sm:h-10 lg:w-20 lg:h-20 shrink-0 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> <path d="M477 174c8.812-13.708 13.948-29.993 13.948-47.466 0-48.55-39.499-88.048-88.05-88.048-32.839 0-61.516 18.084-76.651 44.803-22.773-5.031-46.356-7.586-70.424-7.586-24.003 0-47.624 2.55-70.427 7.582-15.135-26.717-43.813-44.799-76.65-44.799-48.55 0-88.048 39.498-88.048 88.049 0 17.473 5.137 33.759 13.949 47.467C11.987 204.676 0 239.016 0 274.61c0 53.849 27.194 104.194 76.573 141.763C124.677 452.97 188.399 473.125 256 473.125s131.323-20.155 179.427-56.753C484.806 378.803 512 328.457 512 274.609c0-35.59-11.986-69.935-34.822-100.219zM403.076 70.224c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.436-56.701 56.701-56.701zm-294.152 0c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.435-56.701 56.701-56.701zM56.629 197.71a87.57 87.57 0 0 0 52.294 17.264c48.55 0 88.05-39.498 88.05-88.049a88.25 88.25 0 0 0-1.02-13.409c19.464-4.03 39.588-6.075 60.047-6.075 20.515 0 40.613 2.041 60.048 6.068-.672 4.376-1.021 8.857-1.021 13.417 0 48.55 39.498 88.049 88.049 88.049 19.568 0 37.66-6.424 52.294-17.263 15.157 21.633 23.712 45.416 25.08 69.911-23.821 20.076-49.402 34.122-79.949 43.943-38.207 12.281-85.474 18.252-144.5 18.252s-106.292-5.971-144.499-18.253c-30.549-9.821-56.132-23.869-79.952-43.945 1.369-24.496 9.921-48.278 25.079-69.91zm199.372 244.067c-107.337 0-197.317-56.31-219.433-131.312 19.882 13.102 41.246 23.196 65.34 30.94 41.355 13.295 91.76 19.758 154.094 19.758s112.738-6.463 154.095-19.758c24.092-7.745 45.456-17.839 65.338-30.939-22.12 75.002-112.099 131.311-219.434 131.311z"/><use href="#B"/><use href="#B" x="294.153"/><path d="M219.852 256.624c-9.79 0-17.756 8.048-17.756 17.986s7.966 17.992 17.756 17.992c9.802 0 17.767-8.055 17.767-17.992s-7.965-17.986-17.767-17.986zm72.163-.14c-9.885 0-17.902 8.115-17.902 18.126s8.017 18.126 17.902 18.126c9.872 0 17.89-8.115 17.89-18.126s-8.018-18.126-17.89-18.126z"/><defs><path id="B" d="M108.931 105.926c-11.45 0-20.738 9.401-20.738 20.998s9.288 20.998 20.738 20.998c11.436 0 20.725-9.401 20.725-20.998s-9.289-20.998-20.725-20.998z"/></defs></svg>
<span>Покорми Гипнoжабу,<br />притащи сюда img или&nbsp;кликни</span> <span class="text-xs sm:text-sm lg:text-base font-medium text-left lg:text-center">Покорми Гипнoжабу,<br class="hidden lg:inline" /> притащи сюда img или&nbsp;кликни</span>
</div> </div>
<!-- Состояние 2: Файл выбран --> <!-- Состояние 2: Файл выбран -->
<div x-show="hasImage" class="flex flex-col items-center gap-3 text-emerald-600 dark:text-emerald-400"> <div x-show="hasImage" class="flex flex-row lg:flex-col items-center gap-3 text-emerald-600 dark:text-emerald-400 text-left lg:text-center">
<img :src="imagePreviewUrl" alt="Превью" <img :src="imagePreviewUrl" alt="Превью"
class="w-24 h-24 object-cover rounded-xl border-2 border-emerald-500 shadow-md"> class="w-12 h-12 sm:w-14 sm:h-14 lg:w-24 lg:h-24 shrink-0 object-cover rounded-xl border-2 border-emerald-500 shadow-md">
<div class="text-xs font-mono"> <div class="text-xs font-mono">
<p class="font-bold">Гипножаба облизывается на:</p> <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="truncate max-w-[200px] sm: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> <p class="text-[10px] sm:text-[11px] text-stone-400 dark:text-zinc-500 mt-0.5">кликни или притащи другой файл</p>
</div> </div>
</div> </div>
@@ -165,6 +166,8 @@
</div> </div>
</div> </div>
<!-- Блок настроек: табы и параметры -->
<div class="order-5 w-full bg-white dark:bg-zinc-900 p-4 sm:p-6 lg:p-0 rounded-2xl lg:rounded-none border border-stone-200 dark:border-zinc-800 lg:border-0 shadow-sm lg:shadow-none">
<!-- Табы настроек --> <!-- Табы настроек -->
<nav class="flex border-b border-stone-200 dark:border-zinc-800 mb-6 overflow-x-auto no-scrollbar"> <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'" <button type="button" @click="activeTab = 'style'"
@@ -419,9 +422,10 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Индикатор размышления / статус активности --> <!-- Индикатор размышления / статус активности (только десктоп) -->
<div class="h-8 mt-2 flex items-center justify-center"> <div class="hidden lg:flex h-8 mt-2 items-center justify-center">
<div x-show="isLoading" <div x-show="isLoading"
x-transition 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"> 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">
@@ -440,11 +444,12 @@
</div> </div>
<button type="submit" <button type="submit"
:disabled="isLoading || !hasChanges" :disabled="isLoading || !hasChanges"{% if ALLOW_TRACKING %}
{# СТАРТ ГЛЮКОТРОН: Отслеживание Яндекс #}@click="window.ym && ym(112812760, 'reachGoal', 'start_gluco')"{% endif %}
:class="(!hasChanges || isLoading) :class="(!hasChanges || isLoading)
? 'bg-stone-200 dark:bg-zinc-800 text-stone-400 dark:text-zinc-600 cursor-not-allowed shadow-none' ? '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'" : '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"> class="order-3 w-full mt-0 lg:mt-2 font-bold py-3.5 px-6 rounded-xl transition-all flex items-center justify-center gap-2">
<template x-if="isLoading"> <template x-if="isLoading">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<img src="{% static 'img/thinking.svg' %}" class="w-5 h-5 animate-spin [animation-duration:2s]"> <img src="{% static 'img/thinking.svg' %}" class="w-5 h-5 animate-spin [animation-duration:2s]">
@@ -459,35 +464,36 @@
</aside> </aside>
<!-- ПРАВАЯ ПАНЕЛЬ: ПРЕВЬЮ --> <!-- ПРАВАЯ ПАНЕЛЬ: ПРЕВЬЮ -->
<section class="flex flex-col gap-6"> <section class="contents lg:flex lg:flex-col lg:gap-6">
<div id="preview" <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"> :class="hasGenerated ? 'h-auto min-h-[280px] aspect-square lg:aspect-auto lg:h-[744px]' : 'h-14 sm:h-16 lg:h-[744px]'"
class="order-2 w-full bg-stone-200 dark:bg-zinc-900 rounded-2xl lg:rounded-3xl flex items-center justify-center border border-stone-300 dark:border-zinc-800 overflow-hidden relative group transition-all duration-300">
<div class="text-stone-400 flex flex-col items-center gap-4 text-center px-6"> <div class="text-stone-400 flex flex-row lg:flex-col items-center gap-2.5 lg:gap-4 text-center px-4 lg:px-6 py-2 lg:py-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 lg:w-16 lg:h-16 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"
:class="isLoading ? 'animate-pulse text-emerald-500 opacity-80' : 'opacity-50'"> :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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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> </svg>
<p class="transition-all duration-300" <p class="transition-all duration-300 text-xs sm:text-sm lg:text-base font-mono"
:class="isLoading ? 'text-emerald-600 dark:text-emerald-400 font-mono text-xs font-bold uppercase tracking-wider animate-pulse' : 'font-medium opacity-50'" :class="isLoading ? 'text-emerald-600 dark:text-emerald-400 font-mono text-xs font-bold uppercase tracking-wider animate-pulse' : 'font-medium opacity-60'"
x-text="isLoading ? 'Гипножаба переваривает пиксели…' : 'Ваше искусство появится здесь'"></p> x-text="isLoading ? 'Гипножаба переваривает пиксели…' : 'Ваше искусство появится здесь'"></p>
</div> </div>
</div> </div>
{# КНОПКИ КОПИРОВАНИЕ И СКАЧИВАНИЕ (ПОД ПРЕВЬЮ)#} {# КНОПКИ КОПИРОВАНИЕ И СКАЧИВАНИЕ (ПОД ПРЕВЬЮ)#}
<div class="grid grid-cols-2 gap-4"> <div class="order-4 grid grid-cols-2 gap-4 w-full">
<button <button
type="button" type="button"
@click="copySvg()" @click="copySvg(){% if ALLOW_TRACKING %}; window.ym && ym(112812760, 'reachGoal', 'copy_svg'){% endif %}"
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"> 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 text-xs sm:text-sm md:text-base">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 shrink-0" 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>
<span x-text="copied ? 'СКОПИРОВАНО В БУФЕР!' : 'КОПИРОВАТЬ SVG'"></span> <span x-text="copied ? 'СКОПИРОВАНО В БУФЕР!' : 'КОПИРОВАТЬ SVG'"></span>
</button> </button>
<button <button
type="button" type="button"
@click="downloadSvg()" @click="downloadSvg(){% if ALLOW_TRACKING %}; window.ym && ym(112812760, 'reachGoal', 'download_svg'){% endif %}"
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"> 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 text-xs sm:text-sm md:text-base">
<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 shrink-0" 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>
</div> </div>
+2 -2
View File
File diff suppressed because one or more lines are too long
+11 -16
View File
@@ -25,30 +25,25 @@
// ============================================================================ // ============================================================================
// Yandex.Metrika (Яндекс.Метрика) // Yandex.Metrika (Яндекс.Метрика)
// ID: 12345678 // ID: 112812760
// ============================================================================ // ============================================================================
(function() { (function(m,e,t,r,i,k,a){
window.ym = window.ym || function(){ m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
(window.ym.a = window.ym.a || []).push(arguments); m[i].l=1*new Date();
}; for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
window.ym.l = 1 * new Date(); k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document, 'script', 'https://mc.yandex.ru/metrika/tag.js?id=112812760', 'ym');
// Загружаем скрипт Метрики window.ym(112812760, 'init', {
var script = document.createElement('script'); ssr: true,
script.type = 'text/javascript'; webvisor: true,
script.src = 'https://mc.yandex.ru/metrika/tag.js';
document.head.appendChild(script);
// Инициализируем Метрику
window.ym(12345678, 'init', {
trackHash: true,
clickmap: true, clickmap: true,
ecommerce: 'dataLayer',
referrer: document.referrer, referrer: document.referrer,
url: location.href, url: location.href,
accurateTrackBounce: true, accurateTrackBounce: true,
trackLinks: true trackLinks: true
}); });
})();
})(); })();
+175
View File
@@ -0,0 +1,175 @@
/**
* hypn0.js — Основной клиентский скрипт для hypn0.xyz
* Обеспечивает интеграцию HTMX, работу с Declarative Shadow DOM
* и ленивую асинхронную подгрузку SVG-халфтонов через Alpine.js.
*/
// Автоматическая передача CSRF-токена в AJAX-запросах HTMX
document.addEventListener('htmx:configRequest', function(evt) {
var csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
var token = csrfInput ? csrfInput.value : null;
if (!token) {
var match = document.cookie.match(/csrftoken=([^;]+)/);
if (match) token = match[1];
}
if (token) {
evt.detail.headers['X-CSRFToken'] = token;
}
});
// Полифилл / обработка Declarative Shadow DOM для старых браузеров и динамических вставок
function attachShadowRoots(root) {
(root || document).querySelectorAll('template[shadowrootmode]').forEach(function(tmpl) {
if (!tmpl.parentElement.shadowRoot) {
var mode = tmpl.getAttribute('shadowrootmode') || 'open';
var shadow = tmpl.parentElement.attachShadow({ mode: mode });
shadow.appendChild(tmpl.content.cloneNode(true));
tmpl.remove();
}
});
}
document.addEventListener('DOMContentLoaded', function() {
attachShadowRoots(document);
});
// Обработка HTMX swap: инициализация теневого корня и Alpine.js компонентов в новом DOM-фрагменте
document.addEventListener('htmx:afterSwap', function(evt) {
attachShadowRoots(evt.detail.target);
if (window.Alpine && evt.detail && evt.detail.target) {
window.Alpine.initTree(evt.detail.target);
}
});
/**
* hypn0LazySvg — Alpine-компонент ленивой асинхронной загрузки SVG в Shadow DOM.
* Защищает от лагов быстрого скролла (AbortController + debounce),
* размазывает пиковые сетевые всплески (джиттер) и изолирует стили/ID.
*/
function hypn0LazySvg(svgUrl) {
return {
svgUrl: svgUrl || '',
loaded: false,
error: false,
_observer: null,
_controller: null,
_timer: null,
init() {
if (!this.svgUrl) return;
var el = this.$el;
if (!('IntersectionObserver' in window)) {
this.fetchSvg(el);
return;
}
var self = this;
this._observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var jitter = Math.floor(Math.random() * 60);
self._timer = setTimeout(function() {
self.fetchSvg(el);
}, 120 + jitter);
} else {
if (self._timer) {
clearTimeout(self._timer);
self._timer = null;
}
if (self._controller) {
self._controller.abort();
self._controller = null;
}
}
});
}, {
rootMargin: '120px 0px 120px 0px',
threshold: 0.01
});
this._observer.observe(el);
},
fetchSvg(el) {
if (this.loaded) return;
if (this._controller) this._controller.abort();
this._controller = new AbortController();
var self = this;
fetch(this.svgUrl, { signal: this._controller.signal })
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.text();
})
.then(function(svgText) {
if (!svgText) return;
var shadow = el.shadowRoot;
if (!shadow) {
shadow = el.attachShadow({ mode: 'open' });
}
var style = document.createElement('style');
style.textContent = [
':host {',
' display: flex;',
' width: 100%;',
' height: 100%;',
' align-items: center;',
' justify-content: center;',
' pointer-events: auto;',
' opacity: 0;',
' transition: opacity 0.35s ease-out;',
'}',
':host(.is-loaded), :host([data-loaded]) {',
' opacity: 1;',
'}',
'svg {',
' width: 100%;',
' height: 100%;',
' object-fit: contain;',
'}',
':host(:hover) svg, svg:hover {',
' --hypn0-play: running !important;',
'}'
].join('\n');
var parser = new DOMParser();
var doc = parser.parseFromString(svgText, 'image/svg+xml');
var svgEl = doc.querySelector('svg');
shadow.innerHTML = '';
shadow.appendChild(style);
if (svgEl) {
shadow.appendChild(svgEl);
} else {
var container = document.createElement('div');
container.innerHTML = svgText;
var innerSvg = container.querySelector('svg');
if (innerSvg) shadow.appendChild(innerSvg);
}
self.loaded = true;
self.error = false;
el.setAttribute('data-loaded', 'true');
el.classList.add('is-loaded');
if (self._observer) {
self._observer.disconnect();
self._observer = null;
}
})
.catch(function(err) {
if (err.name === 'AbortError') return;
self.error = true;
});
},
destroy() {
if (this._timer) clearTimeout(this._timer);
if (this._controller) this._controller.abort();
if (this._observer) this._observer.disconnect();
}
};
}
+20 -48
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Сборка Tailwind CSS v3 для фронтенда HYPN0 # Сборка Tailwind CSS v4 для фронтенда HYPN0
# Запуск из корня проекта: bash ./scripts/build-tailwind.sh # Запуск из корня проекта: bash ./scripts/build-tailwind.sh
set -euo pipefail set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TAILWIND_DIR="$PROJECT_ROOT/frontend-assembly/tailwind" TAILWIND_DIR="$PROJECT_ROOT/frontend-assembly/tailwind"
OUTPUT_DIR="$PROJECT_ROOT/public/static/css" OUTPUT_DIR="$PROJECT_ROOT/public/static/css"
@@ -18,8 +20,6 @@ fail() {
} }
# cleanup() — удаляет временные файлы при любом завершении скрипта. # cleanup() — удаляет временные файлы при любом завершении скрипта.
# Все рабочие файлы (postcss.config.js, tailwind.config.js, input.css)
# создаются здесь и удаляются в EXIT/INT/TERM.
cleanup() { cleanup() {
rm -rf "$TAILWIND_DIR/node_modules" \ rm -rf "$TAILWIND_DIR/node_modules" \
"$TAILWIND_DIR/postcss.config.js" \ "$TAILWIND_DIR/postcss.config.js" \
@@ -37,67 +37,39 @@ if [[ ! -f "$TAILWIND_DIR/package.json" ]]; then
fail "Не найден package.json: $TAILWIND_DIR/package.json" fail "Не найден package.json: $TAILWIND_DIR/package.json"
fi fi
if [[ ! -f "$TAILWIND_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $TAILWIND_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR" 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 --- # --- input.css ---
log "Создаю input.css" log "Создаю input.css для Tailwind v4"
cat > "$TAILWIND_DIR/input.css" <<'EOF' cat > "$TAILWIND_DIR/input.css" <<'EOF'
/* /*
input.css — точка входа для сборки prod-версии Tailwind CSS v3. input.css — точка входа для сборки prod-версии Tailwind CSS v4.
Собирается через `npm run build` (PostCSS) в Собирается через @tailwindcss/cli в
файл public/static/css/tailwind.min.css, который подключается в _base.html файл public/static/css/tailwind.min.css, который подключается в _base.html
для production (когда settings.DEBUG == False). для production (когда settings.DEBUG == False).
Здесь же подключаются кастомные стили из hypn0/templates/css/tailwind-custom.css — Здесь же подключаются кастомные стили из hypn0/templates/css/tailwind-custom.css.
того же самого файла, который в dev-режиме подключается через {% include %} внутрь
инлайнового <style type="text/tailwindcss"> в _base.html.
Это обеспечивает единый источник кастомных стилей для dev и prod.
*/ */
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @source "../../hypn0/templates";
@source "../../hypn0/hypn0_site";
@import "../../hypn0/templates/css/tailwind-custom.css"; @import "../../hypn0/templates/css/tailwind-custom.css";
EOF EOF
log "СОБИРАЮ Tailwind CSS v3" log "СОБИРАЮ Tailwind CSS v4"
cd "$TAILWIND_DIR" cd "$TAILWIND_DIR"
log 'Устанавливаю зависимости через npm ci' # Если package-lock.json отсутствует или устарел, используем npm install / npm ci
npm ci if [[ -f "$TAILWIND_DIR/package-lock.json" ]]; then
log 'Устанавливаю зависимости через npm install'
npm install --no-audit --no-fund
else
log 'Устанавливаю зависимости через npm install'
npm install --no-audit --no-fund
fi
log 'Собираю CSS' log 'Собираю CSS'
npm run build npm run build