add: галерея (3) первый этаж (плеск бессознательного)
This commit is contained in:
@@ -568,3 +568,132 @@ class UnconsciousMatrixTests(TestCase):
|
||||
self.assertContains(response, f"#{item.s_hash_id}")
|
||||
self.assertContains(response, "btn-nav-prev")
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,7 @@ urlpatterns = [
|
||||
path("publish", views.publish, name="publish"),
|
||||
path("gallery/random", views.gallery_random, name="gallery_random"),
|
||||
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>/download", views.gallery_download, name="gallery_download"),
|
||||
path("gallery/<str:hash_id>/vote", views.gallery_vote, name="gallery_vote"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import random
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
@@ -19,9 +20,63 @@ from .services.halftone import (
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
<!-- Живое появление картины на 1-м этаже галереи через HTMX OOB-Swap -->
|
||||
<div hx-swap-oob="afterbegin:#fresh-stream-grid">
|
||||
{% include "block/gallery_card.html" %}
|
||||
</div>
|
||||
|
||||
{% 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">
|
||||
|
||||
+12
-15
@@ -500,9 +500,9 @@
|
||||
<a href="#" class="text-emerald-600 dark:text-emerald-400 font-bold hover:underline self-start sm:self-auto">Смотреть весь архив →</a>
|
||||
</div>
|
||||
|
||||
<!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадет только что созданный шедевр (нужно чтобы это было интерактивно... лаккнул и сразу через HTMX на первом уровне галереи -->
|
||||
<!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадает только что созданный шедевр -->
|
||||
<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="w-5 h-5 rounded-full bg-amber-500"></div>
|
||||
<div>
|
||||
@@ -513,22 +513,19 @@
|
||||
<p class="text-xs text-stone-400 dark:text-zinc-500">Свежие галлюцинации из инкубатора • Первичная оценка сообщества</p>
|
||||
</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 class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<template x-for="i in 6">
|
||||
<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]">
|
||||
<div class="absolute inset-0 flex items-center justify-center text-stone-400 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-8 h-8 opacity-20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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 id="fresh-stream-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{% for item in fresh_items %}
|
||||
{% include "block/gallery_card.html" %}
|
||||
{% empty %}
|
||||
<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">
|
||||
Инкубатор бессознательного пуст. Сгенерируйте и опубликуйте первую картину выше!
|
||||
</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>
|
||||
</template>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user