diff --git a/hypn0/hypn0/urls.py b/hypn0/hypn0/urls.py
index 71288d3..6cc1c45 100644
--- a/hypn0/hypn0/urls.py
+++ b/hypn0/hypn0/urls.py
@@ -76,6 +76,7 @@ if settings.DEBUG:
urlpatterns = [path('__debug__/', include(debug_toolbar.urls)), ] + urlpatterns
urlpatterns = [*PUBLIC_ROOT_URLPATTERNS, *urlpatterns]
urlpatterns += staticfiles_urlpatterns()
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# ==============================================================================
# РАЗДАЧА МЕДИА-ФАЙЛОВ (/media/...)
diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py
index bd6cfa3..4004190 100644
--- a/hypn0/hypn0_site/tests.py
+++ b/hypn0/hypn0_site/tests.py
@@ -878,9 +878,9 @@ class CardBgStyleTests(BaseMediaTestCase):
self.assertIn('id="test-svg"', item.card_svg)
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-play: running !important")
+ self.assertContains(response, item.file_svg.url)
class GalleryArchiveTests(BaseMediaTestCase):
@@ -1458,7 +1458,7 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
("502", 502, "Контейнер потерял сознание"),
("503", 503, "Плановый сеанс гипнотерапии"),
("504", 504, "Разрыв астральной связи"),
- ("under_reconstruction", 200, "Сектор на реконструкции"),
+ ("under_reconstruction", 200, "Отдел мозга на реконструкции"),
]
for code, expected_status, text_fragment in expected_checks:
diff --git a/hypn0/templates/_base.html b/hypn0/templates/_base.html
index e717449..7e550a3 100644
--- a/hypn0/templates/_base.html
+++ b/hypn0/templates/_base.html
@@ -111,36 +111,19 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
{% endblock EXTRA_LD_JSON %}
]
+
-
{% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %}
-
{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %}
+
+
{% block CONTENT %}{% endblock CONTENT %}
diff --git a/hypn0/templates/block/gallery_card.html b/hypn0/templates/block/gallery_card.html
index e80098b..ec58bd3 100644
--- a/hypn0/templates/block/gallery_card.html
+++ b/hypn0/templates/block/gallery_card.html
@@ -8,32 +8,14 @@
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"
title="«{{ item.s_title }}» — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG"
aria-label="«{{ item.s_title }}» — Векторная картина #{{ item.s_hash_id }} в галерее HypnoSVG">
-
-
- {# Declarative Shadow DOM: изолирует ID () и стили каждой картины в галерее. #}
- {# В отличие от
, события мыши не блокируются, а в отличие от обычного inline SVG нет конфликтов идентификаторов. #}
-
- {{ item.card_svg|safe }}
-
+
diff --git a/public/static/js/hypn0.js b/public/static/js/hypn0.js
new file mode 100644
index 0000000..33fea68
--- /dev/null
+++ b/public/static/js/hypn0.js
@@ -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();
+ }
+ };
+}