add: галерея (4) первый этаж (плеск бессознательного) улучшен 3
This commit is contained in:
@@ -200,6 +200,19 @@ class TbHypn0Item(models.Model):
|
||||
dark_bg = f"rgb({max(9, int(9 + r * 0.07))}, {max(9, int(9 + g * 0.07))}, {max(13, int(13 + b * 0.08))})"
|
||||
return f"--card-bg-light: {light_bg}; --card-bg-dark: {dark_bg};"
|
||||
|
||||
@property
|
||||
def card_svg(self) -> str:
|
||||
"""
|
||||
Возвращает SVG-код картины для изолированного рендеринга в карточке галереи.
|
||||
"""
|
||||
if not self.file_svg:
|
||||
return ""
|
||||
try:
|
||||
with self.file_svg.open("r") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def increment_views(self):
|
||||
"""Безопасный инкремент просмотров"""
|
||||
TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1)
|
||||
|
||||
@@ -229,7 +229,11 @@ def generate_halftone_svg(
|
||||
fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.2f};"
|
||||
|
||||
if is_animated:
|
||||
anim_rule = f"animation:noise {duration:.2f}s ease-in-out infinite alternate;animation-delay:var(--d);"
|
||||
anim_rule = (
|
||||
f"animation:noise {duration:.2f}s ease-in-out infinite alternate;"
|
||||
f"animation-delay:var(--d);"
|
||||
f"animation-play-state:var(--hypn0-play,running);"
|
||||
)
|
||||
keyframes_rule = (
|
||||
f"@keyframes noise{{"
|
||||
f"0%{{opacity:{max(0.2, opacity * 0.7):.2f};transform:scale(1) rotate(0deg)}}"
|
||||
@@ -266,17 +270,19 @@ def generate_halftone_svg(
|
||||
def prepare_gallery_svg(svg_content: str) -> str:
|
||||
"""
|
||||
Модифицирует SVG для долговременного хранения в галерее:
|
||||
добавляет CSS-правила, чтобы в изолированном контексте (<img>) анимация
|
||||
находилась на паузе по умолчанию и запускалась только при наведении (:hover),
|
||||
предотвращая перегрузку CPU/GPU браузера при выводе сетки карточек.
|
||||
добавляет CSS Custom Property (--hypn0-play: paused), которое наследуется
|
||||
внутрь элементов и теневых деревьев <use>. В изолированном контексте (Shadow DOM или <img>)
|
||||
анимация заморожена по умолчанию (0% нагрузки на CPU), а при наведении (:hover или :host(:hover))
|
||||
запускается.
|
||||
"""
|
||||
if not svg_content:
|
||||
return svg_content
|
||||
|
||||
# Надежная глобальная пауза в контексте :root и пробуждение по ховеру
|
||||
# Наследуемая переменная паузы и оверрайд стилей фигур
|
||||
hover_css = (
|
||||
"@media(hover:hover){:root{animation-play-state:paused!important}:root:hover *{animation-play-state:running!important}}"
|
||||
"svg:not(:hover) *{animation-play-state:paused!important}"
|
||||
"svg{--hypn0-play:paused}"
|
||||
":host(:hover) svg,svg:hover{--hypn0-play:running!important}"
|
||||
".shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}"
|
||||
)
|
||||
|
||||
if "</style>" in svg_content:
|
||||
@@ -293,10 +299,24 @@ def prepare_active_svg(svg_content: str) -> str:
|
||||
if not svg_content:
|
||||
return svg_content
|
||||
|
||||
# Удаляем любые внедренные правила пауз и медиа-запросов ховера
|
||||
cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", svg_content)
|
||||
# Удаляем внедренные правила паузы через CSS-переменные и старые форматы
|
||||
gallery_css_variants = [
|
||||
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
|
||||
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
|
||||
]
|
||||
cleaned = svg_content
|
||||
for gcss in gallery_css_variants:
|
||||
cleaned = cleaned.replace(gcss, "")
|
||||
|
||||
cleaned = re.sub(r"svg\s*\{[^}]*--hypn0-play:\s*paused[^}]*\}", "", cleaned)
|
||||
cleaned = re.sub(r"(:host\(:hover\)\s*svg\s*,\s*)?svg:hover\s*\{[^}]*--hypn0-play:[^}]*\}", "", cleaned)
|
||||
cleaned = re.sub(
|
||||
r"\.shape,circle,rect,polygon,path\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}",
|
||||
"",
|
||||
cleaned,
|
||||
)
|
||||
cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", cleaned)
|
||||
cleaned = re.sub(r"svg:not\(:hover\)[^{]*\{[^}]*\}", "", cleaned)
|
||||
# Старый формат для обратной совместимости
|
||||
old_hover_css = (
|
||||
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
|
||||
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
|
||||
|
||||
@@ -238,9 +238,9 @@ class GalleryPreparationTests(TestCase):
|
||||
def test_prepare_gallery_svg(self):
|
||||
raw_svg = '<svg><style>.shape{animation:noise 1s}</style><g></g></svg>'
|
||||
prepared = prepare_gallery_svg(raw_svg)
|
||||
self.assertIn("@media(hover:hover)", prepared)
|
||||
self.assertIn(":root{animation-play-state:paused!important}", prepared)
|
||||
self.assertIn("svg:not(:hover)", prepared)
|
||||
self.assertIn("svg{--hypn0-play:paused}", prepared)
|
||||
self.assertIn(":host(:hover) svg,svg:hover{--hypn0-play:running!important}", prepared)
|
||||
self.assertIn("animation-play-state:var(--hypn0-play,paused)!important", prepared)
|
||||
|
||||
|
||||
class PublishViewTests(TestCase):
|
||||
@@ -322,6 +322,23 @@ class SvgAnalysisAndActiveSvgTests(TestCase):
|
||||
active = prepare_active_svg(gallery_svg)
|
||||
self.assertNotIn("animation-play-state:paused!important", active)
|
||||
|
||||
def test_prepare_active_svg_preserves_color_and_animation(self):
|
||||
gallery_svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
'<style>'
|
||||
'svg{background:transparent}'
|
||||
'.shape,circle,rect,polygon,path{fill:#e6b400;stroke:#e6b400;opacity:0.90;transform-origin:center;animation:noise 1.36s ease-in-out infinite alternate;animation-delay:var(--d);animation-play-state:var(--hypn0-play,running);transition:all .5s ease-out}'
|
||||
'@keyframes noise{0%{opacity:0.63}100%{opacity:0.45}}'
|
||||
'svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}'
|
||||
'</style><g></g></svg>'
|
||||
)
|
||||
active = prepare_active_svg(gallery_svg)
|
||||
self.assertIn("fill:#e6b400", active)
|
||||
self.assertIn("stroke:#e6b400", active)
|
||||
self.assertIn("animation:noise 1.36s", active)
|
||||
self.assertNotIn("svg{--hypn0-play:paused}", active)
|
||||
self.assertNotIn("animation-play-state:var(--hypn0-play,paused)!important", active)
|
||||
|
||||
def test_analyze_svg_structure(self):
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">'
|
||||
@@ -560,7 +577,7 @@ class UnconsciousMatrixTests(TestCase):
|
||||
hashes2 = [n["s_hash_id"] for n in m2]
|
||||
self.assertEqual(hashes1, hashes2)
|
||||
|
||||
def test_gallery_detail_renders_matrix(self):
|
||||
def test_gallery_detail_renders_matrix_and_card_bg_style(self):
|
||||
item = self.items[0]
|
||||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -569,6 +586,9 @@ class UnconsciousMatrixTests(TestCase):
|
||||
self.assertContains(response, f"#{item.s_hash_id}")
|
||||
self.assertContains(response, "btn-nav-prev")
|
||||
self.assertContains(response, "btn-nav-next")
|
||||
self.assertContains(response, "--card-bg-light")
|
||||
self.assertContains(response, "--card-bg-dark")
|
||||
self.assertContains(response, "bg-[var(--card-bg-light)]")
|
||||
|
||||
|
||||
class GalleryFreshFloorTests(TestCase):
|
||||
@@ -729,3 +749,18 @@ class CardBgStyleTests(TestCase):
|
||||
item_invalid = TbHypn0Item(j_metadata={"color": "invalid-hex"})
|
||||
self.assertIn("--card-bg-light:", item_invalid.card_bg_style)
|
||||
self.assertIn("--card-bg-dark:", item_invalid.card_bg_style)
|
||||
|
||||
def test_card_svg_property_and_shadow_dom_rendering(self):
|
||||
item = TbHypn0Item(
|
||||
s_title="Тестовый SVG",
|
||||
file_svg=ContentFile(b'<svg id="test-svg"><circle/></svg>', name="test_card.svg"),
|
||||
j_metadata={"color": "#10b981"},
|
||||
is_public=True,
|
||||
)
|
||||
item.save(visitor_uuid_or_fp="123e4567-e89b-12d3-a456-426614174000")
|
||||
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, "hypn0-card-svg")
|
||||
self.assertContains(response, "--hypn0-play: running !important")
|
||||
|
||||
@@ -121,6 +121,19 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
|
||||
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>
|
||||
|
||||
@@ -7,10 +7,29 @@
|
||||
<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 }}">
|
||||
<img src="{{ item.file_svg.url }}"
|
||||
alt="{{ item.s_title }}"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-contain pointer-events-none drop-shadow-sm transition-transform duration-300 group-hover:scale-105 border-0">
|
||||
<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">
|
||||
<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;
|
||||
}
|
||||
:host(:hover) svg, svg:hover {
|
||||
--hypn0-play: running !important;
|
||||
}
|
||||
</style>
|
||||
{{ item.card_svg|safe }}
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Верхний бейдж: Хэш -->
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Полноэкранный просмотр SVG с живой анимацией и плавающими стрелками -->
|
||||
<div class="relative w-full aspect-square max-h-[640px] flex items-center justify-center bg-stone-950 rounded-2xl overflow-hidden shadow-inner border border-stone-800/60 group select-none">
|
||||
<div style="{{ item.card_bg_style }}"
|
||||
class="relative w-full aspect-square max-h-[640px] 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)]">
|
||||
|
||||
<!-- Плавающая кнопка: Назад -->
|
||||
<a href="{{ prev_url }}"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block TITLE %}{{ floor_title }} — Галерея транса HypnoSVG{% endblock %}
|
||||
|
||||
{% block CONTENT %}
|
||||
<div class="max-w-6xl mx-auto space-y-8">
|
||||
|
||||
<!-- Хлебные крошки и навигация -->
|
||||
<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">
|
||||
<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">
|
||||
Фаза {{ 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="flex flex-wrap items-center gap-3">
|
||||
<h1 class="text-3xl font-black uppercase tracking-tight text-stone-900 dark:text-stone-100">
|
||||
{{ floor_title }}
|
||||
</h1>
|
||||
<span class="text-xs font-bold font-mono px-2.5 py-1 rounded-full bg-{{ floor_badge_color }}-100 dark:bg-{{ floor_badge_color }}-950/70 text-{{ floor_badge_color }}-700 dark:text-{{ floor_badge_color }}-400 border border-{{ floor_badge_color }}-300 dark:border-{{ floor_badge_color }}-800/50">
|
||||
{{ floor_badge }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-stone-500 dark:text-stone-400">
|
||||
{{ floor_subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Сетка картин (8 штук на страницу) -->
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Номера страниц -->
|
||||
<div class="hidden sm:flex items-center gap-1.5 font-mono text-xs">
|
||||
{% 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">
|
||||
{{ p }}
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="?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">
|
||||
{{ p }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
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">
|
||||
<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>Следующая фаза</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>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock CONTENT %}
|
||||
Reference in New Issue
Block a user