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
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/...)
+24
View File
@@ -1,4 +1,6 @@
import hashlib
from html import unescape
import re
from django.core.exceptions import ValidationError
from django.db import models, transaction, IntegrityError
from django.db.models import F
@@ -159,6 +161,17 @@ class TbHypn0Item(models.Model):
def __str__(self) -> str:
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:
"""Возвращает канонический URL детальной страницы картины."""
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:
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:
"""Возвращает канонический URL статьи блога."""
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)
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, "Сектор на&nbsp;реконструкции"),
("under_reconstruction", 200, "Отдел мозга на&nbsp;реконструкции"),
]
for code, expected_status, text_fragment in expected_checks:
@@ -1505,3 +1505,17 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
resp500 = error_500(req500)
self.assertEqual(resp500.status_code, 500)
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 %}
]
</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 %}
+25 -79
View File
@@ -1,79 +1,25 @@
{% load static %}
<div id="gallery-card-{{ item.s_hash_id }}"
style="{{ item.card_bg_style }}"
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)]">
<!-- Ссылка на полный просмотр картины -->
<a href="{% url 'hypn0_site:gallery_detail' item.s_hash_id %}"
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>
</a>
<!-- Верхний бейдж: Хэш и уровень -->
<div class="absolute top-2 left-2 z-20 pointer-events-none flex items-center gap-1">
{% if item.i_level >= 1000 %}
<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">
★ ¤{{ item.s_hash_id }}
</span>
{% elif item.i_level >= 30 %}
<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">
◉ ¤{{ item.s_hash_id }}
</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 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 id="gallery-card-{{ item.s_hash_id }}" style="{{ item.card_bg_style }}" class="card group">{% load static %}
{# <!-- Ссылка на полный просмотр картины -->#}<a href="{% url 'hypn0_site:gallery_detail' item.s_hash_id %}"
class="card-link"
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">
<noscript><div class="card-noscript">
<span class="card-noscript-warn">Включите JS для анимации</span>
<span class="card-noscript-title">«{{ item.s_title|safe }}»</span>
</div>
</noscript>
</div>
</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 %}
{# ОДОБРЕНО ГИПНОЖАБОЙ #}<span class="card-tag card-tag-curated">✦{{ item.s_hash_id }}</span>{% else %}
{# СВЕЖЕЕ #}<span class="card-tag card-tag-fresh">¤{{ item.s_hash_id }}</span>{% endif %}
</div>
{# <!-- Нижняя панель статистики (Лайки и Просмотры) -->#}<div class="card-bottom">{% if item.i_level >= 1000 %}
{# БЕССМЕРТНЫЕ #}<span class="card-stat card-stat-immortal">♥&nbsp;{{ item.i_likes_count }}</span>{% elif item.i_level >= 30 %}
{# ОДОБРЕНО ГИПНОЖАБОЙ #}<span class="card-stat card-stat-curated">♥&nbsp;{{ item.i_likes_count }}</span>{% else %}
{# СВЕЖЕЕ #}<span class="card-stat card-stat-fresh">♥&nbsp;{{ item.i_likes_count }}</span>{% endif %}
<span class="card-stat card-stat-views">👁&nbsp;{{ item.i_views_count }}</span>
</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)
========================================================================== */
+27 -30
View File
@@ -27,49 +27,48 @@
<!-- Хлебные крошки и навигация -->
<div class="flex flex-wrap items-center justify-between gap-4">
<a href="{% url 'hypn0_site:index' %}"
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">
<a href="{% url 'hypn0_site:index' %}" class="archive-back">
<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"/>
</svg>
<span>К генератору</span>
</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 }})
</span>
</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">
<h1 class="text-3xl font-black uppercase tracking-tight text-stone-900 dark:text-stone-100">
<h1 class="archive-title">
Галерея транса
</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
</span>
</div>
<p class="text-sm text-stone-500 dark:text-stone-400">
<p class="archive-desc">
Полный архив психоделических генераций • Сортировка по гравитации, признанию и свежести
</p>
</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">
<!-- Все волны -->
<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 class="font-mono text-[10px] opacity-75">({{ counts.all }})</span>
</a>
<!-- 1-й этаж: Инкубатор -->
<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>Инкубатор</span>
<span class="font-mono text-[10px] opacity-75">({{ counts.fresh }})</span>
@@ -77,7 +76,7 @@
<!-- 2-й этаж: Одобрено -->
<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>Одобрено</span>
<span class="font-mono text-[10px] opacity-75">({{ counts.curated }})</span>
@@ -85,7 +84,7 @@
<!-- 3-й этаж: Золотой фонд -->
<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>Золотой фонд</span>
<span class="font-mono text-[10px] opacity-75">({{ counts.top }})</span>
@@ -93,13 +92,13 @@
</div>
<!-- СЕЛЕКТ СОРТИРОВКИ -->
<div class="flex items-center gap-2 self-end md:self-auto">
<label for="gallery-sort-select" class="text-xs font-bold text-stone-500 dark:text-stone-400 shrink-0">
<div class="archive-sort-wrap">
<label for="gallery-sort-select" class="archive-sort-label">
Порядок:
</label>
<select id="gallery-sort-select"
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 %}
<option value="?floor={{ current_floor }}&sort={{ opt.id }}" {% if current_sort == opt.id %}selected{% endif %}>
{{ opt.icon }} {{ opt.title }}
@@ -111,34 +110,32 @@
</div>
<!-- Сетка картин (16 штук на страницу) -->
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6">
{% for item in page_obj %}
{% include "block/gallery_card.html" %}
{% 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="w-12 h-12 mx-auto opacity-30 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="gallery-grid">{% for item in page_obj %}
{% include "block/gallery_card.html" %}
{% empty %}<div class="archive-empty">
<svg class="archive-empty-icon" 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"/>
</svg>
<p class="font-medium text-sm">В этой выборке пока нет картин. Сгенерируйте шедевр прямо сейчас!</p>
<p class="archive-empty-text">В этой выборке пока нет картин. Сгенерируйте шедевр прямо сейчас!</p>
</div>
{% endfor %}
</div>
<!-- Пагинация (Предыдущая фаза / Номера страниц / Следующая фаза) -->
{% 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>
{% if page_obj.has_previous %}
<a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ page_obj.previous_page_number }}"
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
<span>Предыдущая фаза</span>
</a>
{% 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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
@@ -148,15 +145,15 @@
</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 %}
{% 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 }}
</span>
{% else %}
<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 }}
</a>
{% endif %}
@@ -167,14 +164,14 @@
{% if page_obj.has_next %}
<a href="?floor={{ current_floor }}&sort={{ current_sort }}&page={{ page_obj.next_page_number }}"
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>
<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"/>
</svg>
</a>
{% 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>
<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"/>
+7 -7
View File
@@ -1,11 +1,11 @@
{% extends "_base.html" %}
{% load static %}
{% block PAGE_TITLE %}{{ item.s_title }} — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG{% endblock %}
{% block META_TITLE %}{{ item.s_title }} — Векторный транс #{{ item.s_hash_id }} | HypnoSVG{% endblock %}
{% block META_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ 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_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ 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_plain }} — Векторный транс #{{ item.s_hash_id }} | 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_plain }} — Анимированная SVG-картина #{{ 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 TWITTER_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}
@@ -77,8 +77,8 @@
<!-- Полноэкранный просмотр SVG с живой анимацией и плавающими стрелками -->
<div style="{{ item.card_bg_style }}"
title="«{{ item.s_title }}» — Анимированная SVG-картина #{{ item.s_hash_id }} | HypnoSVG"
aria-label="«{{ item.s_title }}» — Векторная картина #{{ item.s_hash_id }} в галерее HypnoSVG"
title="«{{ item.s_title_plain }}» — Анимированная SVG-картина #{{ 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)]">
<!-- Плавающая кнопка: Назад -->
+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();
}
};
}