game-views carousel: ATLAS/SCROLL/POST/CHAT/PULSE views in the room scroll pane — TDD
Unskips the 8 RED FTs from the prior commit (test_game_room_views.py) and lands the feature beneath them — the room's 2nd vertical snap pane becomes a horizontal scroll-snap carousel of five views, landing on SCROLL (2nd). Carousel core: _room_views.html (5 .room-view panes) + _room_views_strip.html (root-level icon strip, outside the aperture so it clears the scroll card's fade mask + scroll clip); room-views.js owns the horizontal axis — goToView (authoritative active-state) + an IntersectionObserver backing native swipe; horizontal wheel (deltaX / shift+wheel) advances a view while vertical wheel stays for feed scroll; icon-click snaps; the strip shows only while the views pane is on screen (vertical IO mirrors room-scroll.js). SCROLL still wraps _room_scroll.html, so the existing binary y-snap + provenance feed + GAME ROOM ⇄ GAME SCROLL title reel behave unchanged. Title reel: the .gr-swap reel gains the four extra view words; the active word is driven by data-active-view on the h2 (set by room-views.js), gated by .is-scroll (room-scroll.js) so ROOM shows at the hex. POST view: a room-scoped game-table thread. New Post.room FK + KIND_ROOM_THREAD (mirrors Brief.room) + Room.get_thread_post(); epic:room_post AJAX endpoint appends a Line (seated-gamer-gated, dup-rejected) and returns the rendered line partial. _post_line.html extracted from post.html and shared by both surfaces + the endpoint. The composer appends OPTIMISTICALLY (synchronous line so the POST + ATLAS views reflect it the instant OK is clicked, no dependence on the round-trip), then reconciles with the server's authoritative @handle/timestamp render; a rejection rolls the optimistic line back. ATLAS view: a live client-side merge of the SCROLL provenance rows + the POST thread rows, time-ordered, each row tagged data-source=provenance|post for end-of-sprint per-type styling. Rebuilds from the live DOM on activation + on every composer append. CHAT/PULSE are .room-view-stub placeholders (no backing model yet). Burger Text sub-btn lights .active on the table (text_btn_active from epic.room_view, unset on every other _burger.html surface) → room-views.js binds its active click to the swipe machine: DOWN to the views pane, RIGHT to Post. Coverage: 8 carousel FTs green; Jasmine RoomViewsSpec (atlas merge order/stability + row data-source); epic ITs (Room.get_thread_post, carousel markup, room_post endpoint 200/403/400/GET); 1636 ITs/UTs + the existing scroll FT green (no regression). Gotcha logged: build FormData(form) BEFORE clearing the input on optimistic submit — clearing first captures an empty text field → 400 → the line silently rolls back. [[project-room-game-views-carousel]] [[project-room-scroll-of-events]] [[project-room-title-scroll-reel-jun02]] [[feedback-jsonfield-exclude-sqlite-null]] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,10 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billboard.forms import ExistingPostLineForm
|
||||
from apps.drama.models import GameEvent, record
|
||||
from django.db.models import Case, IntegerField, Value, When
|
||||
|
||||
@@ -652,9 +654,50 @@ def room_view(request, room_id):
|
||||
ctx["events"] = room.events.select_related("actor").all()
|
||||
ctx["viewer"] = request.user
|
||||
ctx["scroll_position"] = 0
|
||||
# Game-views carousel (POST view) — the room's single game-table thread.
|
||||
# Lazy-created on first table-page load; its Lines render inline in the
|
||||
# POST view (same `_post_line.html` partial as post.html) and the composer
|
||||
# appends via `epic:room_post`. `text_btn_active` lights the burger fan's
|
||||
# Text sub-btn (the swipe-machine entry to the Post view) on the table only
|
||||
# — it is unset on the gatekeeper / other _burger.html surfaces, which keep
|
||||
# the sub-btn in its inactive flash-stub state.
|
||||
room_post = room.get_thread_post()
|
||||
ctx["room_post"] = room_post
|
||||
ctx["room_post_lines"] = room_post.lines.select_related("author").all()
|
||||
ctx["text_btn_active"] = True
|
||||
return render(request, "apps/gameboard/room.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
def room_post(request, room_id):
|
||||
"""Append a Line to the room's game-table thread (the POST view of the
|
||||
game-views carousel) and return the rendered line partial as JSON, so
|
||||
room-views.js can splice it into `#id_post_table` without a page reload
|
||||
(the carousel must stay put on the POST view). Only seated gamers may
|
||||
speak at the table; the validation mirrors post.html's composer
|
||||
(non-empty + no duplicate line text per thread). GET is a no-op redirect
|
||||
back to the room."""
|
||||
room = Room.objects.get(id=room_id)
|
||||
if request.method != "POST":
|
||||
return redirect("epic:room", room_id=room_id)
|
||||
# Only gamers holding a filled seat at this table may post to its thread.
|
||||
if not room.gate_slots.filter(
|
||||
gamer=request.user, status=GateSlot.FILLED
|
||||
).exists():
|
||||
return HttpResponseForbidden()
|
||||
post = room.get_thread_post()
|
||||
form = ExistingPostLineForm(for_post=post, data=request.POST)
|
||||
if not form.is_valid():
|
||||
return JsonResponse({"ok": False, "errors": form.errors}, status=400)
|
||||
line = form.save(author=request.user)
|
||||
line_html = render_to_string(
|
||||
"apps/billboard/_partials/_post_line.html",
|
||||
{"line": line},
|
||||
request=request,
|
||||
)
|
||||
return JsonResponse({"ok": True, "line_html": line_html})
|
||||
|
||||
|
||||
@login_required
|
||||
def room_gate(request, room_id):
|
||||
"""Room renewal gate-view — reachable mid-game (unlike `gatekeeper`,
|
||||
|
||||
Reference in New Issue
Block a user