2 Commits
Author SHA1 Message Date
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
6 changed files with 204 additions and 55 deletions
@@ -138,6 +138,14 @@ server {
# Даже если внутри контейнера это HTTP на 127.0.0.1:8042, для Django это должно быть 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_connect_timeout 180s;
+1
View File
@@ -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/...)
+3 -3
View File
@@ -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:
+9 -26
View File
@@ -111,36 +111,19 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
{% endblock EXTRA_LD_JSON %}
]
</script>
<script src="{% static 'js/hypn0.js' %}" defer></script>
<script src="{% static 'js/alpine.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 %}
</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" %}
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
{% block CONTENT %}{% endblock CONTENT %}
+8 -26
View File
@@ -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">
<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">
<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 x-data="hypn0LazySvg('{% if item.file_svg %}{{ item.file_svg.url }}{% endif %}')"
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>
<div class="flex flex-col items-center justify-center p-3 text-center gap-1">
<span class="text-[11px] font-bold text-amber-600 dark:text-amber-400">Требуется JS для анимации</span>
<span class="text-[10px] text-stone-500 dark:text-stone-400 underline">«{{ item.s_title }}»</span>
</div>
</noscript>
</div>
</a>
+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();
}
};
}