Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c44f2ef35 | ||
|
|
5e9d4e0902 |
@@ -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;
|
||||
|
||||
@@ -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/...)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user