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 ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %} {% include "block/header.html" %}
{% 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"> -
- +
+
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(); + } + }; +}