add: галерея (4) первый этаж (плеск бессознательного) улучшен 2
This commit is contained in:
@@ -152,6 +152,54 @@ class TbHypn0Item(models.Model):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.s_title} ({self.s_hash_id})"
|
return f"{self.s_title} ({self.s_hash_id})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def card_bg_style(self) -> str:
|
||||||
|
"""
|
||||||
|
Вычисляет утонченную палитру подложки карточки на основе цвета гипноточки.
|
||||||
|
|
||||||
|
Использует CSS-переменные для бесшовной адаптации под текущую тему зрителя (светлая/темная):
|
||||||
|
- Экстремально светлые точки (luminance >= 215, напр. чисто белый): всегда темный фон.
|
||||||
|
- Экстремально темные точки (luminance <= 40, напр. чисто черный): всегда светлый фон.
|
||||||
|
- Цветные/промежуточные оттенки (40 < luminance < 215): изящный тинт цвета точки,
|
||||||
|
который гармонично подстраивается под светлую и темную тему зрителя.
|
||||||
|
"""
|
||||||
|
color_hex = "#a855ff"
|
||||||
|
if isinstance(self.j_metadata, dict):
|
||||||
|
color_hex = self.j_metadata.get("color", "#a855ff") or "#a855ff"
|
||||||
|
|
||||||
|
# Нормализация HEX
|
||||||
|
hex_clean = color_hex.lstrip("#")
|
||||||
|
if len(hex_clean) == 3:
|
||||||
|
hex_clean = "".join([c * 2 for c in hex_clean])
|
||||||
|
elif len(hex_clean) != 6:
|
||||||
|
hex_clean = "a855ff"
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = int(hex_clean[0:2], 16)
|
||||||
|
g = int(hex_clean[2:4], 16)
|
||||||
|
b = int(hex_clean[4:6], 16)
|
||||||
|
except ValueError:
|
||||||
|
r, g, b = 168, 85, 255
|
||||||
|
|
||||||
|
# Перцептивная яркость (ITU-R BT.601)
|
||||||
|
luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||||
|
|
||||||
|
if luminance >= 215:
|
||||||
|
# Экстремально светлая точка (белая/пастельно-белая) -> принудительно темный графит в обеих темах
|
||||||
|
dark_bg = f"rgb({max(8, int(r * 0.05))}, {max(8, int(g * 0.05))}, {max(12, int(b * 0.06))})"
|
||||||
|
return f"--card-bg-light: {dark_bg}; --card-bg-dark: {dark_bg};"
|
||||||
|
elif luminance <= 40:
|
||||||
|
# Экстремально темная точка (черная/глубокая смола) -> принудительно шелково-светлый в обеих темах
|
||||||
|
light_bg = f"rgb({min(248, int(244 + r * 0.03))}, {min(248, int(244 + g * 0.03))}, {min(250, int(246 + b * 0.03))})"
|
||||||
|
return f"--card-bg-light: {light_bg}; --card-bg-dark: {light_bg};"
|
||||||
|
else:
|
||||||
|
# Цветная точка (фиолетовый, изумрудный, бирюзовый, оранжевый и т.д.)
|
||||||
|
# Светлая тема: мягкий шелковый фон с 3.5% тинтом цвета точки
|
||||||
|
light_bg = f"rgb({min(250, int(245 + r * 0.035))}, {min(250, int(245 + g * 0.035))}, {min(252, int(247 + b * 0.035))})"
|
||||||
|
# Темная тема: глубокий графитово-ночной фон с 7% тинтом цвета точки
|
||||||
|
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};"
|
||||||
|
|
||||||
def increment_views(self):
|
def increment_views(self):
|
||||||
"""Безопасный инкремент просмотров"""
|
"""Безопасный инкремент просмотров"""
|
||||||
TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1)
|
TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1)
|
||||||
|
|||||||
@@ -273,9 +273,10 @@ def prepare_gallery_svg(svg_content: str) -> str:
|
|||||||
if not svg_content:
|
if not svg_content:
|
||||||
return svg_content
|
return svg_content
|
||||||
|
|
||||||
|
# Надежная глобальная пауза в контексте :root и пробуждение по ховеру
|
||||||
hover_css = (
|
hover_css = (
|
||||||
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
|
"@media(hover:hover){:root{animation-play-state:paused!important}:root:hover *{animation-play-state:running!important}}"
|
||||||
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
|
"svg:not(:hover) *{animation-play-state:paused!important}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if "</style>" in svg_content:
|
if "</style>" in svg_content:
|
||||||
@@ -292,11 +293,16 @@ def prepare_active_svg(svg_content: str) -> str:
|
|||||||
if not svg_content:
|
if not svg_content:
|
||||||
return svg_content
|
return svg_content
|
||||||
|
|
||||||
hover_css = (
|
# Удаляем любые внедренные правила пауз и медиа-запросов ховера
|
||||||
|
cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", svg_content)
|
||||||
|
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) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
|
||||||
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
|
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
|
||||||
)
|
)
|
||||||
return svg_content.replace(hover_css, "")
|
cleaned = cleaned.replace(old_hover_css, "")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def analyze_svg_structure(svg_content: str) -> dict:
|
def analyze_svg_structure(svg_content: str) -> dict:
|
||||||
|
|||||||
@@ -238,8 +238,9 @@ class GalleryPreparationTests(TestCase):
|
|||||||
def test_prepare_gallery_svg(self):
|
def test_prepare_gallery_svg(self):
|
||||||
raw_svg = '<svg><style>.shape{animation:noise 1s}</style><g></g></svg>'
|
raw_svg = '<svg><style>.shape{animation:noise 1s}</style><g></g></svg>'
|
||||||
prepared = prepare_gallery_svg(raw_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:not(:hover)", prepared)
|
||||||
self.assertIn("animation-play-state:paused!important", prepared)
|
|
||||||
|
|
||||||
|
|
||||||
class PublishViewTests(TestCase):
|
class PublishViewTests(TestCase):
|
||||||
@@ -697,3 +698,34 @@ class GalleryFreshFloorTests(TestCase):
|
|||||||
url = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "non_existent"})
|
url = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "non_existent"})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class CardBgStyleTests(TestCase):
|
||||||
|
"""Тестирование вычисления адаптивных стилей подложки карточки."""
|
||||||
|
|
||||||
|
def test_extreme_white_color_forces_dark_background(self):
|
||||||
|
item = TbHypn0Item(j_metadata={"color": "#ffffff"})
|
||||||
|
style = item.card_bg_style
|
||||||
|
self.assertIn("--card-bg-light: rgb(12, 12, 15);", style)
|
||||||
|
self.assertIn("--card-bg-dark: rgb(12, 12, 15);", style)
|
||||||
|
|
||||||
|
def test_extreme_black_color_forces_light_background(self):
|
||||||
|
item = TbHypn0Item(j_metadata={"color": "#000000"})
|
||||||
|
style = item.card_bg_style
|
||||||
|
self.assertIn("--card-bg-light: rgb(244, 244, 246);", style)
|
||||||
|
self.assertIn("--card-bg-dark: rgb(244, 244, 246);", style)
|
||||||
|
|
||||||
|
def test_colorful_intermediate_color_creates_theme_adaptive_background(self):
|
||||||
|
item = TbHypn0Item(j_metadata={"color": "#a855ff"})
|
||||||
|
style = item.card_bg_style
|
||||||
|
self.assertIn("--card-bg-light: rgb(250, 247, 252);", style)
|
||||||
|
self.assertIn("--card-bg-dark: rgb(20, 14, 33);", style)
|
||||||
|
|
||||||
|
def test_fallback_on_empty_or_invalid_color(self):
|
||||||
|
item_none = TbHypn0Item(j_metadata=None)
|
||||||
|
self.assertIn("--card-bg-light:", item_none.card_bg_style)
|
||||||
|
self.assertIn("--card-bg-dark:", item_none.card_bg_style)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from .services.halftone import (
|
|||||||
from .services.naming import generate_hypno_title
|
from .services.naming import generate_hypno_title
|
||||||
|
|
||||||
|
|
||||||
def get_floor_fresh(limit: int | None = 6, offset: int = 0):
|
def get_floor_fresh(limit: int | None = 8, offset: int = 0):
|
||||||
"""
|
"""
|
||||||
1-й ЭТАЖ: «Плеск бессознательного» (Инкубатор открытий).
|
1-й ЭТАЖ: «Плеск бессознательного» (Инкубатор открытий).
|
||||||
Выборка: свежие кандидаты и первичный поток (Level.CANDIDATE, Level.LEVEL_1).
|
Выборка: свежие кандидаты и первичный поток (Level.CANDIDATE, Level.LEVEL_1).
|
||||||
@@ -73,7 +73,7 @@ def gallery_floor(request: HttpRequest, floor_slug: str) -> HttpResponse:
|
|||||||
|
|
||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def index(request: HttpRequest | None) -> HttpResponse:
|
def index(request: HttpRequest | None) -> HttpResponse:
|
||||||
fresh_items = get_floor_fresh(limit=6)
|
fresh_items = get_floor_fresh(limit=8)
|
||||||
return render(request, "index.html", {
|
return render(request, "index.html", {
|
||||||
"fresh_items": fresh_items,
|
"fresh_items": fresh_items,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<div id="gallery-card-{{ item.s_hash_id }}" class="aspect-square bg-stone-100 dark:bg-zinc-900 rounded-2xl border border-stone-300/80 dark:border-zinc-800 overflow-hidden relative group transition-all duration-300 hover:scale-[1.03] hover:shadow-xl hover:border-amber-500/60 dark:hover:border-amber-500/50 flex items-center justify-center select-none">
|
<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 %}"
|
<a href="{% url 'hypn0_site:gallery_detail' item.s_hash_id %}"
|
||||||
class="absolute inset-0 z-10 flex items-center justify-center p-2.5"
|
class="absolute inset-0 z-10 flex items-center justify-center p-3 sm:p-4"
|
||||||
title="{{ item.s_title }}">
|
title="{{ item.s_title }}">
|
||||||
<img src="{{ item.file_svg.url }}"
|
<img src="{{ item.file_svg.url }}"
|
||||||
alt="{{ item.s_title }}"
|
alt="{{ item.s_title }}"
|
||||||
|
|||||||
@@ -518,7 +518,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="fresh-stream-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div id="fresh-stream-grid" class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6">
|
||||||
{% for item in fresh_items %}
|
{% for item in fresh_items %}
|
||||||
{% include "block/gallery_card.html" %}
|
{% include "block/gallery_card.html" %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
@@ -545,8 +545,8 @@
|
|||||||
<span class="text-xs font-semibold text-stone-400">100–999 лайков</span>
|
<span class="text-xs font-semibold text-stone-400">100–999 лайков</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6">
|
||||||
<template x-for="i in 6">
|
<template x-for="i in 8">
|
||||||
<div class="aspect-square bg-stone-200 dark:bg-zinc-900 rounded-xl border border-stone-300 dark:border-zinc-800 overflow-hidden relative group cursor-pointer hover:border-cyan-500/50 transition-all hover:scale-[1.02]">
|
<div class="aspect-square bg-stone-200 dark:bg-zinc-900 rounded-xl border border-stone-300 dark:border-zinc-800 overflow-hidden relative group cursor-pointer hover:border-cyan-500/50 transition-all hover:scale-[1.02]">
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-stone-400 group-hover:scale-110 transition-transform">
|
<div class="absolute inset-0 flex items-center justify-center text-stone-400 group-hover:scale-110 transition-transform">
|
||||||
<svg class="w-8 h-8 opacity-20" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8 opacity-20" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -577,8 +577,8 @@
|
|||||||
<span class="text-xs font-semibold text-stone-400">1000+ лайков</span>
|
<span class="text-xs font-semibold text-stone-400">1000+ лайков</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6">
|
||||||
<template x-for="i in 6">
|
<template x-for="i in 8">
|
||||||
<div class="aspect-square bg-stone-200 dark:bg-zinc-900 rounded-2xl border-2 border-emerald-500/30 dark:border-emerald-500/20 overflow-hidden relative group cursor-pointer shadow-lg shadow-emerald-950/5 hover:border-emerald-500 transition-all hover:scale-[1.02]">
|
<div class="aspect-square bg-stone-200 dark:bg-zinc-900 rounded-2xl border-2 border-emerald-500/30 dark:border-emerald-500/20 overflow-hidden relative group cursor-pointer shadow-lg shadow-emerald-950/5 hover:border-emerald-500 transition-all hover:scale-[1.02]">
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-emerald-600/40 group-hover:scale-110 transition-transform">
|
<div class="absolute inset-0 flex items-center justify-center text-emerald-600/40 group-hover:scale-110 transition-transform">
|
||||||
<svg class="w-10 h-10 opacity-30" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-10 h-10 opacity-30" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
Reference in New Issue
Block a user