add: галерея (3) первый этаж (плеск бессознательного)

This commit is contained in:
2026-09-01 22:43:55 +03:00
parent e26b6f6fac
commit 8271f881e4
6 changed files with 236 additions and 16 deletions
+129
View File
@@ -568,3 +568,132 @@ class UnconsciousMatrixTests(TestCase):
self.assertContains(response, f"#{item.s_hash_id}") self.assertContains(response, f"#{item.s_hash_id}")
self.assertContains(response, "btn-nav-prev") self.assertContains(response, "btn-nav-prev")
self.assertContains(response, "btn-nav-next") self.assertContains(response, "btn-nav-next")
class GalleryFreshFloorTests(TestCase):
"""Тестирование 1-го этажа («Плеск бессознательного»), выборки и пагинации."""
def setUp(self):
self.client = Client()
self.vid = "123e4567-e89b-12d3-a456-426614174000"
def test_get_floor_fresh_filtering_and_ordering(self):
from hypn0_site.views import get_floor_fresh
# 1. Создаем картины разных уровней и с разным числом просмотров
svg_bytes = b'<svg><circle/></svg>'
# Свежий кандидат с 5 просмотрами
item_cand = TbHypn0Item(
s_title="Кандидат",
file_svg=ContentFile(svg_bytes, name="c.svg"),
i_views_count=5,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
item_cand.save(visitor_uuid_or_fp=self.vid)
# Level 1 с 1 просмотром (должен быть первым, т.к. просмотров меньше)
item_lvl1 = TbHypn0Item(
s_title="Level 1",
file_svg=ContentFile(svg_bytes, name="l1.svg"),
i_views_count=1,
i_level=TbHypn0Item.Level.LEVEL_1,
is_public=True,
)
item_lvl1.save(visitor_uuid_or_fp=self.vid)
# Level 2 (2-й этаж, не должен попасть в 1-й)
item_lvl2 = TbHypn0Item(
s_title="Level 2 Curated",
file_svg=ContentFile(svg_bytes, name="l2.svg"),
i_views_count=0,
i_level=TbHypn0Item.Level.LEVEL_2,
is_public=True,
)
item_lvl2.save(visitor_uuid_or_fp=self.vid)
# Непубличная картина (не должна попасть)
item_private = TbHypn0Item(
s_title="Private",
file_svg=ContentFile(svg_bytes, name="p.svg"),
i_views_count=0,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=False,
)
item_private.save(visitor_uuid_or_fp=self.vid)
results = list(get_floor_fresh(limit=10))
self.assertEqual(len(results), 2)
# Сначала с наименьшим i_views_count
self.assertEqual(results[0].pk, item_lvl1.pk)
self.assertEqual(results[1].pk, item_cand.pk)
def test_index_view_renders_fresh_stream(self):
# Создаем 2 картины для 1 этажа
svg_bytes = b'<svg><circle/></svg>'
item = TbHypn0Item(
s_title="Свежий шедевр",
file_svg=ContentFile(svg_bytes, name="f1.svg"),
i_views_count=2,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
item.save(visitor_uuid_or_fp=self.vid)
response = self.client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Плеск бессознательного")
self.assertContains(response, "FRESH STREAM")
self.assertContains(response, f"gallery-card-{item.s_hash_id}")
self.assertContains(response, f"¤{item.s_hash_id}")
def test_publish_includes_oob_swap_for_fresh_grid(self):
self.client.cookies["hypn0_vid"] = self.vid
sample_svg = '<svg xmlns="http://www.w3.org/2000/svg"><g><circle/></g></svg>'
response = self.client.post(
reverse("hypn0_site:publish"),
data={
"svg_content": sample_svg,
"shape": "circle",
"cols": "35",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'hx-swap-oob="afterbegin:#fresh-stream-grid"')
item = TbHypn0Item.objects.first()
self.assertContains(response, f"gallery-card-{item.s_hash_id}")
def test_gallery_floor_fresh_pagination(self):
# Создаем 10 картин
svg_bytes = b'<svg><circle/></svg>'
for i in range(10):
item = TbHypn0Item(
s_title=f"Кандидат #{i}",
file_svg=ContentFile(svg_bytes, name=f"cand_{i}.svg"),
i_views_count=i,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
item.save(visitor_uuid_or_fp=self.vid)
# Страница 1 (должно быть 8 штук)
url = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "fresh"})
response_p1 = self.client.get(url)
self.assertEqual(response_p1.status_code, 200)
self.assertContains(response_p1, "Плеск бессознательного")
self.assertContains(response_p1, "FRESH STREAM")
self.assertEqual(len(response_p1.context["page_obj"]), 8)
self.assertContains(response_p1, "Фаза 1 из 2")
# Страница 2 (должно быть 2 штуки)
response_p2 = self.client.get(url, data={"page": 2})
self.assertEqual(response_p2.status_code, 200)
self.assertEqual(len(response_p2.context["page_obj"]), 2)
self.assertContains(response_p2, "Фаза 2 из 2")
def test_gallery_floor_unknown_404(self):
url = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "non_existent"})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
+1
View File
@@ -11,6 +11,7 @@ urlpatterns = [
path("publish", views.publish, name="publish"), path("publish", views.publish, name="publish"),
path("gallery/random", views.gallery_random, name="gallery_random"), path("gallery/random", views.gallery_random, name="gallery_random"),
path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"), path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"),
path("gallery/floor/<slug:floor_slug>", views.gallery_floor, name="gallery_floor"),
path("gallery/<str:hash_id>", views.gallery_detail, name="gallery_detail"), path("gallery/<str:hash_id>", views.gallery_detail, name="gallery_detail"),
path("gallery/<str:hash_id>/download", views.gallery_download, name="gallery_download"), path("gallery/<str:hash_id>/download", views.gallery_download, name="gallery_download"),
path("gallery/<str:hash_id>/vote", views.gallery_vote, name="gallery_vote"), path("gallery/<str:hash_id>/vote", views.gallery_vote, name="gallery_vote"),
+56 -1
View File
@@ -3,6 +3,7 @@ import random
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.paginator import Paginator
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
@@ -19,9 +20,63 @@ 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):
"""
1-й ЭТАЖ: «Плеск бессознательного» (Инкубатор открытий).
Выборка: свежие кандидаты и первичный поток (Level.CANDIDATE, Level.LEVEL_1).
Сортировка: по наименьшему числу просмотров i_views_count (чтобы дать шанс всем) и свежести -d_created_at.
"""
qs = TbHypn0Item.objects.filter(
i_level__in=[TbHypn0Item.Level.CANDIDATE, TbHypn0Item.Level.LEVEL_1],
is_public=True,
).order_by("i_views_count", "-d_created_at")
if limit is not None:
return qs[offset : offset + limit]
return qs
def gallery_floor(request: HttpRequest, floor_slug: str) -> HttpResponse:
"""
Страница полного просмотра конкретного этажа галереи с пагинацией (по 8 карточек).
"""
floors_config = {
"fresh": {
"title": "Плеск бессознательного",
"badge": "FRESH STREAM",
"badge_color": "amber",
"subtitle": "Свежие галлюцинации из инкубатора • Первичная оценка сообщества",
"getter": get_floor_fresh,
},
}
if floor_slug not in floors_config:
raise Http404("Этаж транса не обнаружен в матрице")
cfg = floors_config[floor_slug]
items_qs = cfg["getter"](limit=None)
paginator = Paginator(items_qs, 8)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
context = {
"floor_slug": floor_slug,
"floor_title": cfg["title"],
"floor_badge": cfg["badge"],
"floor_badge_color": cfg["badge_color"],
"floor_subtitle": cfg["subtitle"],
"page_obj": page_obj,
}
return render(request, "gallery/floor.html", context)
@ensure_csrf_cookie @ensure_csrf_cookie
def index(request: HttpRequest | None) -> HttpResponse: def index(request: HttpRequest | None) -> HttpResponse:
return render(request, "index.html", {}) fresh_items = get_floor_fresh(limit=6)
return render(request, "index.html", {
"fresh_items": fresh_items,
})
def tmp(request: HttpRequest | None) -> HttpResponse: def tmp(request: HttpRequest | None) -> HttpResponse:
+33
View File
@@ -0,0 +1,33 @@
{% 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">
<!-- Ссылка на полный просмотр картины -->
<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"
title="{{ item.s_title }}">
<img src="{{ item.file_svg.url }}"
alt="{{ item.s_title }}"
loading="lazy"
class="w-full h-full object-contain pointer-events-none drop-shadow-sm transition-transform duration-300 group-hover:scale-105">
</a>
<!-- Верхний бейдж: Хэш -->
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-stone-900/70 text-stone-200 backdrop-blur-md border border-white/10 opacity-75 group-hover:opacity-100 transition-opacity">
¤{{ item.s_hash_id }}
</span>
</div>
<!-- Нижняя панель статистики (Лайки и Просмотры) -->
<div class="absolute bottom-2 left-2 right-2 z-20 flex items-center justify-between pointer-events-none">
<!-- Бейдж лайков -->
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-amber-400 backdrop-blur-md border border-amber-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
<!-- Бейдж просмотров (проявляется при наведении) -->
<span class="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-stone-900/80 text-stone-400 backdrop-blur-md opacity-0 group-hover:opacity-100 transition-opacity">
👁 {{ item.i_views_count }}
</span>
</div>
</div>
@@ -51,6 +51,11 @@
<span class="font-mono text-[11px] bg-purple-900/80 text-purple-300 border border-purple-400/30 px-2 py-0.5 rounded-md">#{{ item.s_hash_id }}</span> <span class="font-mono text-[11px] bg-purple-900/80 text-purple-300 border border-purple-400/30 px-2 py-0.5 rounded-md">#{{ item.s_hash_id }}</span>
</div> </div>
<!-- Живое появление картины на 1-м этаже галереи через HTMX OOB-Swap -->
<div hx-swap-oob="afterbegin:#fresh-stream-grid">
{% include "block/gallery_card.html" %}
</div>
{% elif error %} {% elif error %}
<!-- Ошибка публикации --> <!-- Ошибка публикации -->
<div id="gallery-candidate-action" class="pointer-events-auto flex items-center gap-3 bg-rose-600/90 text-white text-xs font-medium px-4 py-2.5 rounded-full shadow-xl backdrop-blur-md border border-rose-400/30"> <div id="gallery-candidate-action" class="pointer-events-auto flex items-center gap-3 bg-rose-600/90 text-white text-xs font-medium px-4 py-2.5 rounded-full shadow-xl backdrop-blur-md border border-rose-400/30">
+12 -15
View File
@@ -500,9 +500,9 @@
<a href="#" class="text-emerald-600 dark:text-emerald-400 font-bold hover:underline self-start sm:self-auto">Смотреть весь архив →</a> <a href="#" class="text-emerald-600 dark:text-emerald-400 font-bold hover:underline self-start sm:self-auto">Смотреть весь архив →</a>
</div> </div>
<!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадет только что созданный шедевр (нужно чтобы это было интерактивно... лаккнул и сразу через HTMX на первом уровне галереи --> <!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадает только что созданный шедевр -->
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full bg-amber-500"></div> <div class="w-5 h-5 rounded-full bg-amber-500"></div>
<div> <div>
@@ -513,22 +513,19 @@
<p class="text-xs text-stone-400 dark:text-zinc-500">Свежие галлюцинации из инкубатора • Первичная оценка сообщества</p> <p class="text-xs text-stone-400 dark:text-zinc-500">Свежие галлюцинации из инкубатора • Первичная оценка сообщества</p>
</div> </div>
</div> </div>
<span class="text-xs font-semibold text-stone-400">Свежее • до 100 лайков</span> <a href="{% url 'hypn0_site:gallery_floor' 'fresh' %}" class="text-xs font-bold text-amber-600 dark:text-amber-400 hover:underline">
Смотреть весь поток →
</a>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div id="fresh-stream-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<template x-for="i in 6"> {% for item in fresh_items %}
<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-amber-500/50 transition-all hover:scale-[1.02]"> {% include "block/gallery_card.html" %}
<div class="absolute inset-0 flex items-center justify-center text-stone-400 group-hover:scale-110 transition-transform"> {% empty %}
<svg class="w-8 h-8 opacity-20" fill="currentColor" viewBox="0 0 24 24"> <div class="col-span-full py-10 text-center text-stone-400 bg-stone-100/50 dark:bg-zinc-900/50 rounded-2xl border border-dashed border-stone-300 dark:border-zinc-800 text-xs">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/> Инкубатор бессознательного пуст. Сгенерируйте и опубликуйте первую картину выше!
</svg>
</div>
<div class="absolute bottom-2 left-2 flex items-center gap-1 text-[11px] font-bold text-amber-600 dark:text-amber-400 bg-white/90 dark:bg-zinc-900/90 px-2 py-0.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<span x-text="Math.floor(Math.random()*99) + 1"></span>
</div>
</div> </div>
</template> {% endfor %}
</div> </div>
</div> </div>