Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc1897cc62 | ||
|
|
e7f348d95e |
+14
-14
@@ -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(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from hypn0_site.models import TbHypn0Item
|
||||
from hypn0_site.services.naming import generate_hypno_title
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Массовое переименование названий генераций (TbHypn0Item.s_title) на английский язык.
|
||||
|
||||
АРХИТЕКТУРНОЕ НАЗНАЧЕНИЕ:
|
||||
=========================
|
||||
1. Локализация каталога и прод-базы:
|
||||
Переводит существующие русскоязычные названия картин в базе данных
|
||||
на сочные англоязычные гипнотические тайтлы в стиле Гипножабы.
|
||||
|
||||
2. Детерминированность vs Случайность:
|
||||
По умолчанию использует детерминированный сид на базе ID генерации (item.id + seed_offset),
|
||||
что обеспечивает стабильность результатов при повторных запусках (идемпотентность).
|
||||
Флаг --random позволяет генерировать полностью случайные тайтлы.
|
||||
|
||||
3. Безопасность и оптимизация:
|
||||
- Пакетное обновление через bulk_update порциями (--batch-size).
|
||||
- Режим предварительного просмотра (--dry-run).
|
||||
- Фильтрация по умолчанию только кириллических / пустых названий (или всех с флагом --all).
|
||||
"""
|
||||
|
||||
help = "Массовое переименование названий генераций на английский язык"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Тестовый прогон: показать изменения без сохранения в базу данных",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Переименовать абсолютно все генерации (по умолчанию только с кириллицей или пустые)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only-cyrillic",
|
||||
action="store_true",
|
||||
help="Переименовывать только записи с кириллическими символами в названии (включено по умолчанию)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=500,
|
||||
help="Размер пакета для bulk_update (по умолчанию: 500)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Ограничить количество обрабатываемых записей",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-offset",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Смещение для детерминированного генератора сидов (по умолчанию: 0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--random",
|
||||
action="store_true",
|
||||
help="Использовать недетерминированные случайные сиды вместо ID записи",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run: bool = options["dry_run"]
|
||||
rename_all: bool = options["all"]
|
||||
batch_size: int = options["batch_size"]
|
||||
limit: Optional[int] = options["limit"]
|
||||
seed_offset: int = options["seed_offset"]
|
||||
use_random: bool = options["random"]
|
||||
|
||||
self.stdout.write(
|
||||
self.style.NOTICE(
|
||||
f"Старт процесса переименования генераций "
|
||||
f"(dry_run={dry_run}, rename_all={rename_all}, batch_size={batch_size}, random={use_random})..."
|
||||
)
|
||||
)
|
||||
|
||||
cyrillic_pattern = re.compile(r"[а-яА-ЯёЁ]")
|
||||
|
||||
qs = TbHypn0Item.objects.all().only("id", "s_title").order_by("id")
|
||||
total_in_db = qs.count()
|
||||
|
||||
if total_in_db == 0:
|
||||
self.stdout.write(self.style.WARNING("В базе данных нет генераций для обработки."))
|
||||
return
|
||||
|
||||
to_update = []
|
||||
renamed_count = 0
|
||||
preview_samples = []
|
||||
|
||||
for item in qs.iterator(chunk_size=batch_size):
|
||||
old_title = item.s_title or ""
|
||||
|
||||
# Проверяем критерий необходимости переименования
|
||||
needs_rename = False
|
||||
if rename_all:
|
||||
needs_rename = True
|
||||
else:
|
||||
# По умолчанию: кириллица или пустой заголовок
|
||||
if not old_title.strip() or cyrillic_pattern.search(old_title):
|
||||
needs_rename = True
|
||||
|
||||
if not needs_rename:
|
||||
continue
|
||||
|
||||
# Генерация нового англоязычного тайтла
|
||||
seed = None if use_random else (item.id + seed_offset)
|
||||
new_title = generate_hypno_title(seed=seed)
|
||||
|
||||
if len(preview_samples) < 15:
|
||||
preview_samples.append((item.id, old_title, new_title))
|
||||
|
||||
item.s_title = new_title
|
||||
to_update.append(item)
|
||||
renamed_count += 1
|
||||
|
||||
if limit is not None and renamed_count >= limit:
|
||||
break
|
||||
|
||||
# Пакетное сохранение порциями
|
||||
if not dry_run and len(to_update) >= batch_size:
|
||||
with transaction.atomic():
|
||||
TbHypn0Item.objects.bulk_update(to_update, fields=["s_title"])
|
||||
to_update.clear()
|
||||
|
||||
# Сохранение остатка
|
||||
if not dry_run and to_update:
|
||||
with transaction.atomic():
|
||||
TbHypn0Item.objects.bulk_update(to_update, fields=["s_title"])
|
||||
to_update.clear()
|
||||
|
||||
# Вывод примеров переименования
|
||||
if preview_samples:
|
||||
self.stdout.write("\nПримеры переименований (первые до 15 записей):")
|
||||
self.stdout.write("-" * 70)
|
||||
for item_id, old_t, new_t in preview_samples:
|
||||
self.stdout.write(f" [ID {item_id:5d}] «{old_t}» -> «{new_t}»")
|
||||
self.stdout.write("-" * 70)
|
||||
|
||||
self.stdout.write(f"\nВсего записей в базе данных: {total_in_db}")
|
||||
self.stdout.write(f"Отобрано для переименования: {renamed_count}")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"\n[DRY RUN] Проверено {renamed_count} записей. Изменения НЕ сохранены в БД. "
|
||||
"Запустите без флага --dry-run для применения."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nУспешно переименовано {renamed_count} генераций в базе данных!")
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 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 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 Parallel Continuum",
|
||||
"Noosphere Psycho-Artifacts",
|
||||
"Astral Council Field Notes",
|
||||
"Reflexive Responses of Brain Slugs",
|
||||
"Dispatches from Subconscious Depths",
|
||||
"Materials from 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 the 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, "Гипноузел не обнаружен", status_code=404)
|
||||
self.assertContains(response, "СБОЙ МАТРИЦЫ // КОД? 404", status_code=404)
|
||||
self.assertContains(response, "Hypno-Node Not Found", status_code=404)
|
||||
self.assertContains(response, "MATRIX ANOMALY // CODE 404", status_code=404)
|
||||
self.assertContains(response, "return-btn", status_code=404)
|
||||
self.assertContains(response, "Стираем остатки несущес­твующей страницы", 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, "Гипноузел не обнаружен"),
|
||||
("413", 413, "Перегрузка сенсорных каналов"),
|
||||
("429", 429, "Слишком много мыслей в секунду"),
|
||||
("500", 500, "Критический перегрев неокортекса"),
|
||||
("502", 502, "Контейнер потерял сознание"),
|
||||
("503", 503, "Плановый сеанс гипнотерапии"),
|
||||
("504", 504, "Разрыв астральной связи"),
|
||||
("under_reconstruction", 200, "Отдел мозга на реконструкции"),
|
||||
("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, "Гипноузел не обнаружен", 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("Гипноузел не обнаружен", 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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user