mod: update Russian to English translations in templates and forms for improved accessibility
HYPN0 Build and Push Docker Image / build-and-push (push) Successful in 31s

This commit is contained in:
2026-09-27 21:12:57 +03:00
parent e7f348d95e
commit dc1897cc62
7 changed files with 270 additions and 120 deletions
+14 -14
View File
@@ -6,25 +6,25 @@ class HalftoneGenerateForm(forms.Form):
Форма валидации входных параметров для генерации гипнотического SVG.
"""
SHAPE_CHOICES = [
("circle", "Круг"),
("ring", "Кольцо"),
("triangle", "Треугольник"),
("square", "Квадрат"),
("diamond", "Ромб"),
("hexagon", "Шестиугольник"),
("star", "Звезда"),
("cross", "Крестик"),
("line", "Линия"),
("snowflake", "Снежинка"),
("binary", "Матрица (0/1)"),
("heart", "Сердечко"),
("circle", "Circle"),
("ring", "Ring"),
("triangle", "Triangle"),
("square", "Square"),
("diamond", "Diamond"),
("hexagon", "Hexagon"),
("star", "Star"),
("cross", "Cross"),
("line", "Line"),
("snowflake", "Snowflake"),
("binary", "Binary (0/1)"),
("heart", "Heart"),
]
image = forms.ImageField(
required=True,
error_messages={
"required": "Покорми Гипножабу изображением!",
"invalid_image": "Гипножаба не может переварить этот файл. Нужна картинка (PNG, JPG, WEBP).",
"required": "Feed the Hypnotoad with an image!",
"invalid_image": "Hypnotoad cannot digest this file. Image required (PNG, JPG, WEBP).",
},
)
shape = forms.ChoiceField(
+7 -7
View File
@@ -596,13 +596,13 @@ class TbBlogPost(models.Model):
def blog_nav_title(self) -> str:
"""Детерминированный выбор названия раздела блога для навигации на основе ID статьи."""
nav_titles = (
"Блог Мозгоклюя",
"Записки Гипножабы",
"Дневник Мозговых Перцепторов",
"Хроники психо-резонанса",
"Мемуары астрального архива",
"Манифесты волновой матрицы",
"Бортовой журнал транс-погружения",
"Brain Slug Blog",
"Hypnotoad Field Notes",
"Neural Perceptor Diary",
"Psycho-Resonance Chronicles",
"Astral Archive Memoirs",
"Wave Matrix Manifestos",
"Deep Trance Flight Log",
)
if not self.id:
return nav_titles[0]
+1 -1
View File
@@ -47,7 +47,7 @@ HYPNO_GENITIVES = [
HYPNO_POSTFIXES = [
"#42", "v3.14", "(Ultra)", "(Phase 7)", "(Alpha)", "(Omega)", "(Protocol 09)", "(Revision 14)",
"(Extract)", "(Layer 9)", "(Ward 106)", "(Hangar 18)", "(Unfinished Opus)", "(Secret Project)",
"[Classified]", "(DOOP-66)", "(Sub-Level 4)", "(MK-Ultra)", "С90"
"[Classified]", "(DOOP-66)", "(Sub-Level 4)", "(MK-Ultra)", "C90"
]
+65 -65
View File
@@ -232,7 +232,7 @@ class HalftoneViewTests(BaseMediaTestCase):
self.assertContains(response, 'X-CSRFToken')
self.assertContains(response, "handleFileChange")
self.assertContains(response, "imagePreviewUrl")
self.assertContains(response, "Гипножаба облизывается на:")
self.assertContains(response, "Hypnotoad salivates over:")
# Проверяем, что csrf_token не пустой в hx-headers
content = response.content.decode("utf-8")
self.assertNotIn('hx-headers=\'{"X-CSRFToken": ""}\'', content)
@@ -291,7 +291,7 @@ class HalftoneViewTests(BaseMediaTestCase):
def test_generate_post_no_image(self):
response = self.client.post(reverse("hypn0_site:generate"), data={"shape": "circle"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Покорми Гипножабу")
self.assertContains(response, "Feed the Hypnotoad")
def test_generate_post_success(self):
buf = io.BytesIO()
@@ -366,8 +366,8 @@ class PublishViewTests(BaseMediaTestCase):
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Мозговые слизняки протестуют!")
self.assertContains(response, "Подчиниться и опубликовать")
self.assertContains(response, "Brain Slugs are rioting!")
self.assertContains(response, "Submit and Publish")
self.assertEqual(TbHypn0Item.objects.count(), 0)
def test_publish_with_cookie_success(self):
@@ -389,8 +389,8 @@ class PublishViewTests(BaseMediaTestCase):
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Уже в галерее транса")
self.assertContains(response, "идет прорастание психо-паутины...")
self.assertContains(response, "In the Trance Gallery")
self.assertContains(response, "psychic neural web expanding…")
# Проверяем запись в БД
self.assertEqual(TbHypn0Item.objects.count(), 1)
@@ -419,7 +419,7 @@ class PublishViewTests(BaseMediaTestCase):
data={"svg_content": ""},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Нет данных SVG для публикации")
self.assertContains(response, "No SVG data for publication")
self.assertEqual(TbHypn0Item.objects.count(), 0)
@@ -522,11 +522,11 @@ class GalleryDetailAndDownloadTests(BaseMediaTestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Астральный Транс Сознания #42")
self.assertContains(response, f"#{self.item.s_hash_id}")
self.assertContains(response, "Векторная анатомия")
self.assertContains(response, "Синтез психо-сетки")
self.assertContains(response, "Астральный паспорт")
self.assertContains(response, "Копировать SVG-код")
self.assertContains(response, "Скачать .svg")
self.assertContains(response, "Vector Anatomy")
self.assertContains(response, "Psych-Grid Synthesis")
self.assertContains(response, "Astral Passport")
self.assertContains(response, "Copy SVG code")
self.assertContains(response, "Download .svg")
# Проверка инкремента просмотров
self.item.refresh_from_db()
@@ -557,7 +557,7 @@ class GalleryDetailAndDownloadTests(BaseMediaTestCase):
client = Client()
response = client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Подчинитесь воле Гипножабы!")
self.assertContains(response, "Surrender to Hypnotoad!")
class GalleryRandomAndNavigationTests(BaseMediaTestCase):
@@ -699,8 +699,8 @@ class UnconsciousMatrixTests(BaseMediaTestCase):
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)
self.assertContains(response, "Матрица бессознательного")
self.assertContains(response, "5 узлов")
self.assertContains(response, "Matrix of the Unconscious")
self.assertContains(response, "5 nodes")
self.assertContains(response, f"#{item.s_hash_id}")
self.assertContains(response, "btn-nav-prev")
self.assertContains(response, "btn-nav-next")
@@ -782,7 +782,7 @@ class GalleryFreshFloorTests(BaseMediaTestCase):
response = self.client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Плеск бессознательного")
self.assertContains(response, "Incubator of the Unconscious")
self.assertContains(response, "FRESH STREAM")
self.assertContains(response, f"gallery-card-{item.s_hash_id}")
self.assertContains(response, f"¤{item.s_hash_id}")
@@ -935,12 +935,12 @@ class GalleryArchiveTests(BaseMediaTestCase):
url = reverse("hypn0_site:gallery_archive")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Галерея транса")
self.assertContains(response, "Trance Gallery")
self.assertContains(response, "ARCHIVE")
self.assertContains(response, "Все волны")
self.assertContains(response, "Инкубатор")
self.assertContains(response, "Одобрено")
self.assertContains(response, "Золотой фонд")
self.assertContains(response, "All waves")
self.assertContains(response, "Incubator")
self.assertContains(response, "Approved")
self.assertContains(response, "Hall of Fame")
# Все 3 картины присутствуют в выдаче
self.assertContains(response, f"gallery-card-{self.item_fresh.s_hash_id}")
self.assertContains(response, f"gallery-card-{self.item_curated.s_hash_id}")
@@ -1013,21 +1013,21 @@ class GalleryArchiveTests(BaseMediaTestCase):
resp_p1 = self.client.get(url, data={"floor": "all", "sort": "likes"})
self.assertEqual(resp_p1.status_code, 200)
self.assertEqual(len(resp_p1.context["page_obj"]), 16)
self.assertContains(resp_p1, "Фаза 1 из 2")
self.assertContains(resp_p1, "<title>Галерея транса — Каталог анимированных картин | HypnoSVG</title>")
self.assertContains(resp_p1, "Phase 1 of&nbsp;2")
self.assertContains(resp_p1, "<title>Trance Gallery — Animated SVG Catalog | HypnoSVG</title>")
# Проверяем сохранение query-параметров в ссылках пагинатора
self.assertContains(resp_p1, "floor=all&sort=likes&page=2")
resp_p2 = self.client.get(url, data={"floor": "all", "sort": "likes", "page": 2})
self.assertEqual(resp_p2.status_code, 200)
self.assertEqual(len(resp_p2.context["page_obj"]), 2)
self.assertContains(resp_p2, "Фаза 2 из 2")
self.assertContains(resp_p2, "<title>Галерея транса — Каталог анимированных картин (Фаза 2) | HypnoSVG</title>")
self.assertContains(resp_p2, "Phase 2 of&nbsp;2")
self.assertContains(resp_p2, "<title>Trance Gallery — Animated SVG Catalog (Phase 2) | HypnoSVG</title>")
self.assertContains(resp_p2, "floor=all&sort=likes&page=1")
# Проверяем Title при фильтрации по этажу
resp_curated = self.client.get(url, data={"floor": "curated"})
self.assertContains(resp_curated, "<title>Одобрено Мозговым Слизнем | HypnoSVG</title>")
self.assertContains(resp_curated, "<title>Approved by Brain Slug | HypnoSVG</title>")
class RescoreCommandTests(BaseMediaTestCase):
@@ -1311,12 +1311,12 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
# Проверяем, что отображается один из динамических заголовков секции
content = response.content.decode("utf-8")
possible_headings = (
"Сигналы из параллельного континуума",
"Психо-артефакты ноосферы",
"Заметки астрального консилиума",
"Рефлекторные отклики мозговых слизней",
"Публикации из глубин подсознания",
"Материалы хроник гипноза",
"Signals from&nbsp;Parallel Continuum",
"Noosphere Psycho-Artifacts",
"Astral Council Field Notes",
"Reflexive Responses of&nbsp;Brain Slugs",
"Dispatches from&nbsp;Subconscious Depths",
"Materials from&nbsp;Hypno Chronicles",
)
self.assertTrue(any(heading in content for heading in possible_headings))
self.assertContains(response, "Хроники гипно-транса: Урок 1")
@@ -1345,7 +1345,7 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
# Проверка 1-й страницы ленты блога
response = self.client.get("/blog")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Хроники гипноза")
self.assertContains(response, "Hypno Chronicles")
self.assertContains(response, "Тестовая депеша 11")
self.assertNotContains(response, "Черновик ноосферы")
self.assertContains(response, "page=2")
@@ -1388,7 +1388,7 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
resp_parent = self.client.get(f"/blog/{series_parent.slug}")
self.assertEqual(resp_parent.status_code, 200)
self.assertContains(resp_parent, "Цикл: Основы векторного гипноза")
self.assertContains(resp_parent, "Материалы и главы этого цикла")
self.assertContains(resp_parent, "Chapters in This Series")
self.assertContains(resp_parent, "Глава 1: Первичная модуляция")
self.assertContains(resp_parent, "Глава 2: Фазовый сдвиг матрицы")
@@ -1396,31 +1396,31 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
resp_ch1 = self.client.get(f"/blog/{ch1.slug}")
self.assertEqual(resp_ch1.status_code, 200)
self.assertContains(resp_ch1, "Глава 1: Первичная модуляция")
self.assertContains(resp_ch1, "Материал входит в серию:")
self.assertContains(resp_ch1, "Part of&nbsp;the&nbsp;series:")
self.assertContains(resp_ch1, self.item.s_title)
self.assertContains(resp_ch1, f"#{self.item.s_hash_id}")
self.assertContains(resp_ch1, f"/gallery/{self.item.s_hash_id}")
self.assertContains(resp_ch1, "Скачать SVG")
self.assertContains(resp_ch1, "Вес:")
self.assertContains(resp_ch1, "Следующая глава цикла")
self.assertContains(resp_ch1, "Download SVG")
self.assertContains(resp_ch1, "Weight:")
self.assertContains(resp_ch1, "Next chapter in series")
self.assertContains(resp_ch1, "Глава 2: Фазовый сдвиг матрицы")
# Проверка хлебных крошек
self.assertContains(resp_ch1, 'aria-label="Хлебные крошки"')
self.assertContains(resp_ch1, 'aria-label="Breadcrumbs"')
self.assertContains(resp_ch1, ch1.blog_nav_title)
self.assertContains(resp_ch1, "Глава 1 из 2")
self.assertContains(resp_ch1, "Chapter 1 of 2")
self.assertContains(resp_ch1, 'aria-current="page"')
# Проверка JSON-LD BreadcrumbList
self.assertContains(resp_ch1, '"@type": "BreadcrumbList"')
self.assertContains(resp_ch1, '"name": "Главная"')
self.assertContains(resp_ch1, '"name": "Хроники гипноза"')
self.assertContains(resp_ch1, '"name": "Home"')
self.assertContains(resp_ch1, '"name": "Hypno Chronicles"')
self.assertContains(resp_ch1, series_parent.s_title)
# 3. Проверка второй главы (ссылка назад на главу 1)
resp_ch2 = self.client.get(f"/blog/{ch2.slug}")
self.assertEqual(resp_ch2.status_code, 200)
self.assertContains(resp_ch2, "Предыдущая глава цикла")
self.assertContains(resp_ch2, "Previous chapter in series")
self.assertContains(resp_ch2, "Глава 1: Первичная модуляция")
# 4. Проверка доступа по числовому ID
@@ -1437,10 +1437,10 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
"""Проверка отдачи кастомной страницы 404 в режиме DEBUG=False."""
response = self.client.get("/totally-non-existent-hypno-route-404")
self.assertEqual(response.status_code, 404)
self.assertContains(response, "Гипноузел не&nbsp;обнаружен", status_code=404)
self.assertContains(response, "СБОЙ МАТРИЦЫ // КОД?&nbsp;404", status_code=404)
self.assertContains(response, "Hypno-Node Not Found", status_code=404)
self.assertContains(response, "MATRIX ANOMALY // CODE&nbsp;404", status_code=404)
self.assertContains(response, "return-btn", status_code=404)
self.assertContains(response, "Стираем остатки несущес&shy;твующей страницы", status_code=404)
self.assertContains(response, "Erasing traces of the phantom page", status_code=404)
@override_settings(DEBUG=True)
def test_debug_error_preview_routing(self):
@@ -1448,23 +1448,23 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
# 1. Индексный список страниц ошибок
resp_list = self.client.get("/_error/")
self.assertEqual(resp_list.status_code, 200)
self.assertContains(resp_list, "Центр отладки страниц ошибок")
self.assertContains(resp_list, "Error Pages Debug Center")
self.assertContains(resp_list, "/_error/404")
self.assertContains(resp_list, "готов к просмотру")
self.assertContains(resp_list, "ready to preview")
# 2. Проверка каждого кода ошибки
expected_checks = [
("400", 400, "Сбой ментального протокола"),
("401", 401, "Требуется ментальная идентификация"),
("403", 403, "Отказано бюрократом 24-го уровня"),
("404", 404, "Гипноузел не&nbsp;обнаружен"),
("413", 413, "Перегрузка сенсорных каналов"),
("429", 429, "Слишком много мыслей в&nbsp;секунду"),
("500", 500, "Критический перегрев неокортекса"),
("502", 502, "Контейнер потерял сознание"),
("503", 503, "Плановый сеанс гипнотерапии"),
("504", 504, "Разрыв астральной связи"),
("under_reconstruction", 200, "Отдел мозга на&nbsp;реконструкции"),
("400", 400, "Perceptual Protocol Mismatch"),
("401", 401, "Subconscious Identity Required"),
("403", 403, "Cognitive Lockdown Active"),
("404", 404, "Hypno-Node Not Found"),
("413", 413, "Sensory Channel Saturation"),
("429", 429, "Too Many Thoughts per Second"),
("500", 500, "Introspection Neural Lock"),
("502", 502, "Uplink to Hippocampus Lost"),
("503", 503, "Scheduled Hypnotherapy in Progress"),
("504", 504, "Astral Synapse Timed Out"),
("under_reconstruction", 200, "Brain Sector Under Reconstruction"),
]
for code, expected_status, text_fragment in expected_checks:
@@ -1478,12 +1478,12 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
# 3. Предпросмотр с суффиксом .html
resp_404_html = self.client.get("/_error/404.html")
self.assertEqual(resp_404_html.status_code, 404)
self.assertContains(resp_404_html, "Гипноузел не&nbsp;обнаружен", status_code=404)
self.assertContains(resp_404_html, "Hypno-Node Not Found", status_code=404)
# 4. Несуществующий шаблон ошибки в dev-режиме
resp_non_existent = self.client.get("/_error/non-existent-code-999")
self.assertEqual(resp_non_existent.status_code, 404)
self.assertContains(resp_non_existent, "Шаблон для ошибки 'non-existent-code-999' не найден", status_code=404)
self.assertContains(resp_non_existent, "Template for error 'non-existent-code-999' not found", status_code=404)
def test_custom_django_error_handlers(self):
"""Проверка прямого вызова стандартных Django-обработчиков ошибок."""
@@ -1495,22 +1495,22 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
req400 = factory.get("/bad-request")
resp400 = error_400(req400)
self.assertEqual(resp400.status_code, 400)
self.assertIn("Сбой ментального протокола", resp400.content.decode("utf-8"))
self.assertIn("Perceptual Protocol Mismatch", resp400.content.decode("utf-8"))
req403 = factory.get("/forbidden")
resp403 = error_403(req403)
self.assertEqual(resp403.status_code, 403)
self.assertIn("Отказано бюрократом 24-го уровня", resp403.content.decode("utf-8"))
self.assertIn("Cognitive Lockdown Active", resp403.content.decode("utf-8"))
req404 = factory.get("/not-found")
resp404 = error_404(req404)
self.assertEqual(resp404.status_code, 404)
self.assertIn("Гипноузел не&nbsp;обнаружен", resp404.content.decode("utf-8"))
self.assertIn("Hypno-Node Not Found", resp404.content.decode("utf-8"))
req500 = factory.get("/server-error")
resp500 = error_500(req500)
self.assertEqual(resp500.status_code, 500)
self.assertIn("Критический перегрев неокортекса", resp500.content.decode("utf-8"))
self.assertIn("Introspection Neural Lock", resp500.content.decode("utf-8"))
def test_s_title_plain_property(self):
"""Проверка очистки HTML-тегов и мнемоник в s_title_plain для моделей TbHypn0Item и TbBlogPost."""
+10 -10
View File
@@ -196,14 +196,14 @@ def debug_error_preview(request: HttpRequest, code: str = "") -> HttpResponse:
break
except TemplateDoesNotExist:
pass
status_mark = "готов к просмотру" if exists else "шаблон не создан"
status_mark = "ready to preview" if exists else "template not found"
links.append(f"<li><a href='/_error/{c}'><strong>/{c}</strong></a> ({status_mark})</li>")
html = f"""<!doctype html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dev: Предпросмотр страниц ошибок // HypnoSVG</title>
<title>Dev: Error Pages Preview // HypnoSVG</title>
<style>
body {{ font-family: ui-monospace, monospace; background: #09090b; color: #d9f99d; padding: 2rem; line-height: 1.6; }}
a {{ color: #a3e635; text-decoration: none; border-bottom: 1px dotted #a3e635; }}
@@ -215,8 +215,8 @@ def debug_error_preview(request: HttpRequest, code: str = "") -> HttpResponse:
</head>
<body>
<div class="card">
<h2>Центр отладки страниц ошибок (DEBUG=True)</h2>
<p>Выберите код для инспекции верстки и интерактивных скриптов перекалибровки:</p>
<h2>Error Pages Debug Center (DEBUG=True)</h2>
<p>Select an error code to inspect layout and recalibration scripts:</p>
<ul>
{''.join(links)}
</ul>
@@ -244,7 +244,7 @@ def debug_error_preview(request: HttpRequest, code: str = "") -> HttpResponse:
if not found_template:
return HttpResponse(
f"<h1>Шаблон для ошибки '{raw_name}' не найден</h1><p>Проверены: {', '.join(template_candidates)}</p><p><a href='/_error/'>← Вернуться к списку</a></p>",
f"<h1>Template for error '{raw_name}' not found</h1><p>Checked: {', '.join(template_candidates)}</p><p><a href='/_error/'>← Back to index</a></p>",
status=404,
content_type="text/html; charset=utf-8",
)
@@ -281,7 +281,7 @@ def generate(request: HttpRequest) -> HttpResponse:
form = HalftoneGenerateForm(request.POST, request.FILES)
if not form.is_valid():
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации"
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Generation parameter error"
return render(request, "block/preview.html", {"error": error_msg})
image_file = form.cleaned_data["image"]
@@ -313,7 +313,7 @@ def generate(request: HttpRequest) -> HttpResponse:
return render(
request,
"block/preview.html",
{"error": f"Сбой в матрице гипноза: {str(e)}"},
{"error": f"Glitch in hypno matrix: {str(e)}"},
)
return render(
@@ -338,7 +338,7 @@ def publish(request: HttpRequest) -> HttpResponse:
return render(
request,
"block/publish_status.html",
{"error": "Нет данных SVG для публикации. Попробуйте сгенерировать заново."},
{"error": "No SVG data for publication. Please try generating again."},
)
visitor_uuid = request.COOKIES.get("hypn0_vid")
@@ -411,7 +411,7 @@ def publish(request: HttpRequest) -> HttpResponse:
return render(
request,
"block/publish_status.html",
{"error": f"Сбой фиксации в трансе: {str(e)}"},
{"error": f"Failed to record in trance: {str(e)}"},
)
return render(
+1 -1
View File
@@ -11,7 +11,7 @@
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-2 md:gap-x-4 justify-self-start text-sm sm:text-base md:text-xl font-semibold text-slate-900 dark:text-slate-100">
<a href="{% url 'hypn0_site:gallery_archive' %}" class="px-2.5 py-1 sm:px-4 sm:py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg border-0 text-slate-900 dark:text-slate-100">gallery</a>
<a href="{% url 'hypn0_site:blog_feed' %}" class="px-2.5 py-1 sm:px-4 sm:py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden md:block hyphens-none border-0 text-slate-900 dark:text-slate-100">blog</a>
<a href="#" class="px-2.5 py-1 sm:px-4 sm:py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden lg:block border-0 text-slate-900 dark:text-slate-100" @click.prevent="darkMode = !darkMode; localStorage.setItem('darkMode', darkMode)" title="Light/Dark theme"><span x-text="darkMode?'●':'○'"></span></a>
<a href="#" class="px-2.5 py-1 sm:px-4 sm:py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg border-0 text-slate-900 dark:text-slate-100" @click.prevent="darkMode = !darkMode; localStorage.setItem('darkMode', darkMode)" title="Light/Dark theme"><span x-text="darkMode?'●':'○'"></span></a>
</menu>{# /ПРАВОЕ МЕНЮ #}
</div>
</header>
+172 -22
View File
@@ -5,11 +5,11 @@
{% block META_TITLE %}{{ post.s_title|striptags }} — HypnoSVG{% endblock %}
{% block META_DESCRIPTION %}{% if post.s_teaser %}{{ post.s_teaser|striptags|truncatechars:160 }}{% else %}Dispatch from the Hypno Chronicles of HypnoSVG: {{ post.s_title|striptags }}{% endif %}{% endblock %}
{% block META_DESCRIPTION %}{% if post.s_teaser %}{{ post.s_teaser|striptags|truncatechars:"160" }}{% else %}Dispatch from the Hypno Chronicles of HypnoSVG: {{ post.s_title|striptags }}{% endif %}{% endblock %}
{% block OG_TITLE %}{{ post.s_title|striptags }} | HypnoSVG{% endblock %}
{% block OG_DESCRIPTION %}{% if post.s_teaser %}{{ post.s_teaser|striptags|truncatechars:200 }}{% else %}Dispatch from the Hypno Chronicles of HypnoSVG: {{ post.s_title|striptags }}{% endif %}{% endblock %}
{% block OG_DESCRIPTION %}{% if post.s_teaser %}{{ post.s_teaser|striptags|truncatechars:"200" }}{% else %}Dispatch from the Hypno Chronicles of HypnoSVG: {{ post.s_title|striptags }}{% endif %}{% endblock %}
{% block OG_IMAGE %}{% if post.f_cover_img %}{{ request.scheme }}://{{ request.get_host }}{{ post.f_cover_img.url }}{% elif post.k_item and post.k_item.file_svg %}{{ request.scheme }}://{{ request.get_host }}{{ post.k_item.file_svg.url }}{% else %}{% static 'img/og-hypn0-default.png' %}{% endif %}{% endblock %}
@@ -57,8 +57,8 @@
"@type": "Article",
"headline": "{{ post.s_title|striptags|escapejs }}",
"description": "{{ post.s_teaser|striptags|escapejs }}",
"datePublished": "{{ post.d_published_at|date:'r' }}",
"dateModified": "{{ post.d_updated_at|date:'r' }}",
"datePublished": "{{ post.d_published_at|date:"r" }}",
"dateModified": "{{ post.d_updated_at|date:"r" }}",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ request.scheme }}://{{ request.get_host }}{{ post.get_absolute_url }}"
@@ -116,27 +116,21 @@
</div>
{% endif %}
<!-- ЕДИНАЯ ДВУХКОЛОНОЧНАЯ СЕТКА: 1/4 слева (превью + SVG), 3/4 справа (заголовок + дата + тизер + контент) -->
{% if post.f_cover_img and post.k_item %}
<!-- ДВА БЛОКА: 1. Верхний ряд (обложка слева + заголовок/тизер справа) и 2. Нижний ряд (SVG/экспликация слева + контент справа) -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 items-start">
<!-- 1. Верхний ряд слева (1/4): Тизер-превью обложки -->
<div class="md:col-span-1 w-full pb-8">
{% if post.f_cover_img %}{# ЕСТЬ ТИЗЕР-КАРТИНКА И ПОКАЗЫВАЕМ ЕË #}
<div class="aspect-square sm:aspect-[4/3] md:aspect-square rounded-2xl overflow-hidden border border-stone-200 dark:border-zinc-800 shadow-md bg-stone-100 dark:bg-zinc-900">
<div class="order-2 md:order-1 md:col-span-1 w-full pb-8">
<div class="aspect-square rounded-2xl overflow-hidden border border-stone-200 dark:border-zinc-800 shadow-md bg-stone-100 dark:bg-zinc-900">
<img src="{{ post.f_cover_img.url }}"
alt="{{ post.s_title|striptags }}"
class="w-full h-full object-cover">
</div>
{% else %}{# НЕТ ТИЗЕР-КАРТИНКИ И ПОКАЗЫВАЕМ ЗАГЛУШКУ #}
<div class="aspect-square sm:aspect-[4/3] md:aspect-square rounded-2xl border border-dashed border-stone-300 dark:border-zinc-700 bg-stone-100/40 dark:bg-zinc-900/40 flex flex-col items-center justify-center p-3 text-stone-400 dark:text-stone-600 text-center">
<img src="{% static 'img/brain-slug.svg' %}" alt="Brain Slug" class="w-10 h-10 opacity-40 mb-1">{# <-- СЮДА ДЕФОЛТНУЮ КАРТИНКУ #}
<span class="text-[10px] uppercase tracking-wider">No visual transmitted</span>{# <-- А ПОСЛЕ ЭТО УБРАТЬ #}
</div>
{% endif %}
</div>
<!-- 1. Верхний ряд справа (3/4): Заголовок + Дата + Тизер с непрерывной пунктирной линией -->
<div class="md:col-span-3 border-l-1 border-purple-500 border-dotted pl-6 pb-8 space-y-4">
<div class="order-1 md:order-2 md:col-span-3 border-l-0 md:border-l-1 border-purple-500 border-dotted pl-0 md:pl-6 pb-8 space-y-4">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-black tracking-tight text-stone-900 dark:text-stone-100 leading-tight">
{{ post.s_title|safe }}
</h1>
@@ -161,12 +155,11 @@
{% endif %}
</div>
<!-- 2. Нижний ряд слева (1/4): Связанный SVG + Музейная экспликация (выровнены по вертикали с первой строкой контента) -->
<div class="md:col-span-1 space-y-4">
{% if post.k_item %}
<!-- 2. Нижний ряд слева (1/4): Связанный SVG + Музейная экспликация под ним -->
<div class="order-2 md:order-1 md:col-span-1 space-y-4 pt-6 md:pt-0">
<aside class="space-y-3 md:sticky md:top-6">
<!-- Интерактивное SVG-полотно экспоната -->
<div class="aspect-square rounded-2xl overflow-hidden bg-stone-950 shadow-md flex items-center justify-center p-2 select-none hypn0-detail-canvas [&>svg]:w-full [&>svg]:h-full [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:object-contain">
<div class="aspect-square rounded-2xl overflow-hidden bg-[var(--card-bg-light)] dark:bg-[var(--card-bg-dark)] border border-stone-300/80 dark:border-zinc-800/80 shadow-md flex items-center justify-center p-2 select-none hypn0-detail-canvas [&>svg]:w-full [&>svg]:h-full [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:object-contain">
{% if active_svg %}
<div class="w-full h-full flex items-center justify-center pointer-events-auto">
<template shadowrootmode="open">
@@ -265,11 +258,10 @@
</div>
</div>
</aside>
{% endif %}
</div>
<!-- 2. Нижний ряд справа (3/4): Текстовый блок статьи -->
<div class="md:col-span-3 border-l-1 border-purple-500 border-dotted pl-6">
<div class="order-1 md:order-2 md:col-span-3 border-l-0 md:border-l-1 border-purple-500 border-dotted pl-0 md:pl-6">
<div class="prose max-w-none font-sans text-base sm:text-lg">
{{ post.s_content|safe }}
</div>
@@ -277,6 +269,164 @@
</div>
{% else %}
<!-- ЕДИНЫЙ БЛОК: Левая колонка (SVG/экспликация, либо обложка, либо заглушка) + Правая колонка (заголовок + дата + тизер + контент) -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 items-start">
<!-- Левая колонка (1/4) -->
<div class="order-2 md:order-1 md:col-span-1 space-y-4 pt-6 md:pt-0">
{% if post.k_item %}{# 1. ЕСТЬ SVG (И НЕТ ОБЛОЖКИ) — ПОКАЗЫВАЕМ SVG И ПОД НИМ ЭКСПЛИКАЦИЮ #}
<aside class="space-y-3 md:sticky md:top-6">
<!-- Интерактивное SVG-полотно экспоната -->
<div class="aspect-square rounded-2xl overflow-hidden bg-[var(--card-bg-light)] dark:bg-[var(--card-bg-dark)] border border-stone-300/80 dark:border-zinc-800/80 shadow-md flex items-center justify-center p-2 select-none hypn0-detail-canvas [&>svg]:w-full [&>svg]:h-full [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:object-contain">
{% if active_svg %}
<div class="w-full h-full flex items-center justify-center pointer-events-auto">
<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%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
--hypn0-play: running !important;
}
</style>
{{ active_svg|safe }}
</template>
</div>
{% elif post.k_item.file_svg %}
<img src="{{ post.k_item.file_svg.url }}" alt="{{ post.k_item.s_title }}" class="max-w-full max-h-full object-contain">
{% endif %}
</div>
<!-- Музейная экспликация (Табличка с описанием экспоната) -->
<div class="bg-stone-100/80 dark:bg-zinc-900/80 border border-stone-200/80 dark:border-zinc-800 rounded-2xl p-3.5 space-y-2.5 text-xs shadow-sm">
<div class="text-right">
<div class="font-bold text-stone-900 dark:text-stone-100 text-sm leading-snug">
{{ post.k_item.s_title|safe }}
</div>
<div class="flex items-center justify-end gap-1.5 my-2.5">
<a href="{% url 'hypn0_site:gallery_detail' post.k_item.s_hash_id %}"
class="text-[10px]"
title="View artwork in gallery">#{{ post.k_item.s_hash_id }}</a>
</div>
<div class="flex items-center justify-end gap-1.5 mt-1.5">
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-700 dark:text-purple-300 border border-purple-500/20 font-bold">
{{ post.k_item.get_i_level_display }}
</span>
</div>
</div>
<div class="border-t border-stone-200/60 dark:border-zinc-800 pt-2 space-y-1.5 text-[11px] text-stone-600 dark:text-stone-400">
{% if svg_stats.total_oscillators %}
<div class="flex justify-between items-center">
<span class="opacity-70">Oscillators:</span>
<span class="font-bold text-stone-900 dark:text-stone-200">{{ svg_stats.total_oscillators }}</span>
</div>
{% endif %}
{% if svg_stats.viewbox %}
<div class="flex justify-between items-center">
<span class="opacity-70">viewBox:</span>
<span class="font-mono text-purple-600 dark:text-purple-400">{{ svg_stats.viewbox }}</span>
</div>
{% endif %}
{% if svg_stats.total_kb %}
<div class="flex justify-between items-center">
<span class="opacity-70">Weight:</span>
<span class="font-mono text-stone-900 dark:text-stone-200">{{ svg_stats.total_kb }} KB</span>
</div>
{% endif %}
<div class="flex justify-between items-center">
<span><svg class="w-3.5 h-3.5 text-emerald-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg></span>
<span class="inline-flex items-center gap-1 font-bold text-stone-900 dark:text-stone-200">
{{ post.k_item.i_views_count }}
</span>
</div>
<div class="flex justify-between items-center">
<span><svg class="w-3.5 h-3.5 fill-rose-500 text-rose-500 shrink-0" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg></span>
<span class="inline-flex items-center gap-1 font-bold text-stone-900 dark:text-stone-200">
{{ post.k_item.i_likes_count }}
</span>
</div>
</div>
<!-- Действия с экспонатом -->
<div class="border-t border-stone-200/60 dark:border-zinc-800 pt-2 flex flex-col gap-1.5">
<a href="{% url 'hypn0_site:gallery_download' post.k_item.s_hash_id %}"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-stone-200/70 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-700 dark:text-stone-300 transition-all border border-stone-300/50 dark:border-zinc-700/50 border-0 text-center">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span>Download SVG</span>
</a>
</div>
</div>
</aside>
{% elif post.f_cover_img %}{# 2. ЕСТЬ ТОЛЬКО ОБЛОЖКА #}
<div class="aspect-square rounded-2xl overflow-hidden border border-stone-200 dark:border-zinc-800 shadow-md bg-stone-100 dark:bg-zinc-900">
<img src="{{ post.f_cover_img.url }}"
alt="{{ post.s_title|striptags }}"
class="w-full h-full object-cover">
</div>
{% else %}{# 3. НЕТ НИ SVG, НИ ОБЛОЖКИ — ДЕФОЛТНАЯ ЗАГЛУШКА #}
<div class="aspect-square rounded-2xl border border-dashed border-stone-300 dark:border-zinc-700 bg-stone-100/40 dark:bg-zinc-900/40 flex flex-col items-center justify-center p-3 text-stone-400 dark:text-stone-600 text-center">
<img src="{% static 'img/brain-slug.svg' %}" alt="Brain Slug" class="w-10 h-10 opacity-40 mb-1">
<span class="text-[10px] uppercase tracking-wider">No visual transmitted</span>
</div>
{% endif %}
</div>
<!-- Правая колонка (3/4): Заголовок + Дата + Тизер + Контент -->
<div class="order-1 md:order-2 md:col-span-3 border-l-0 md:border-l-1 border-purple-500 border-dotted pl-0 md:pl-6 space-y-6">
<div class="space-y-4">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-black tracking-tight text-stone-900 dark:text-stone-100 leading-tight">
{{ post.s_title|safe }}
</h1>
<!-- Дата и время публикации статьи в рамочке с иконкой часов -->
<div class="flex items-center justify-start">
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-purple-500/10 text-purple-700 dark:text-purple-300 border border-purple-500/20 font-bold text-sm">
<svg class="w-3.5 h-3.5 text-purple-700 dark:text-purple-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<time datetime="{{ post.d_published_at|date:"c" }}">
{{ post.d_published_at|date:"r" }}
</time>
</div>
</div>
<!-- Лид / Тизер статьи -->
{% if post.s_teaser %}
<div class="text-base sm:text-lg text-stone-600 dark:text-stone-300 leading-relaxed font-sans p-4 sm:p-5 bg-stone-100 dark:bg-zinc-900 border border-stone-200 dark:border-zinc-800 rounded-2xl">
{{ post.s_teaser|safe }}
</div>
{% endif %}
</div>
<!-- Основной текст статьи -->
<div class="prose max-w-none font-sans text-base sm:text-lg">
{{ post.s_content|safe }}
</div>
</div>
</div>
{% endif %}
<!-- РАЗДЕЛ СЕРИИ: список глав (если статья является родительской подборкой/серией) -->
{% if series_posts and not series_parent %}
<section class="space-y-4 pt-8 border-t border-stone-200 dark:border-zinc-800">
@@ -292,7 +442,7 @@
<div class="bg-stone-100/70 dark:bg-zinc-900/70 rounded-2xl border border-stone-200 dark:border-zinc-800 p-4 space-y-2 hover:border-purple-400 dark:hover:border-purple-600 transition-colors">
<div class="flex items-center justify-between text-[11px] text-stone-400">
<span class="font-bold text-purple-600 dark:text-purple-400">Chapter {{ child.i_order }}</span>
<time datetime="{{ child.d_published_at|date:"c" }}">{{ child.d_published_at|date:'r' }}</time>
<time datetime="{{ child.d_published_at|date:"c" }}">{{ child.d_published_at|date:"r" }}</time>
</div>
<h3 class="font-bold text-sm text-stone-900 dark:text-stone-100">
<a href="{{ child.get_absolute_url }}" class="hover:text-purple-600 dark:hover:text-purple-400 transition-colors border-0">