3 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
11 changed files with 497 additions and 146 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;
+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, "Записки Гипножабы: Раздел&Смысл")
+9 -26
View File
@@ -111,36 +111,19 @@ 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 %}
+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>
+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"/>
+7 -7
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)]">
<!-- Плавающая кнопка: Назад --> <!-- Плавающая кнопка: Назад -->
+1 -1
View File
File diff suppressed because one or more lines are too long
+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();
}
};
}