my-sea spectator Phase B: seat-2C occupancy + visitor token gate + one-shot seated glow + gear BYE — TDD
Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED invitee can watch the owner's my-sea read-only, deposit a token to occupy seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's my_sea.html is left structurally intact — the spectator gets a dedicated, simpler my_sea_visit.html; the read-only draw reuses the existing `latest_draw_slots` payload (no picker surgery). - B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED SeaInvite(owner, request.user); owner bounced to their own my_sea. Context forces owner-only controls off (sea_btn_active=False, read_only=True); renders the table hex (1C owner / 2C visitor) + owner draw read-only. - B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a spectator branch (titles the OWNER's Sea, INSERT posts to the visitor endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step my_sea_visit_insert_token selects+debits the visitor's token (same priority chain) and records token_deposited_at + a 24h voice_until on the SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW. - B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at, clears voice_until (frees 2C, ends voice), redirects /gameboard/. _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages pass no leave_url, so unchanged). - B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a per-occupancy data-seat-token) an occupied seat flares --terUser + --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban already swapped to .fa-circle-check). _room.scss adds .seated / .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's .active→.role-confirmed handoff). Wired on BOTH the spectator page (load) and the owner page (load + on the FREE DRAW seat-1 transition). MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal. - B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit → VIEW DRAW + seat 2C seated. URLs: my-sea/visit/<uuid:owner_id>/ (+ /gate/, /insert, /leave). 470 IT/UT green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice + coturn droplet) next — the 24h voice_until window set here drives it. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,5 +11,14 @@
|
||||
{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}
|
||||
<div id="id_my_sea_menu" style="display:none">
|
||||
<button type="button" class="btn btn-cancel" onclick="location.href='{{ nvm_url }}'">NVM</button>
|
||||
{# Spectator-only BYE (Phase B) — drops presence (status=LEFT, frees seat #}
|
||||
{# 2C, kills voice). Rendered below NVM only when the caller passes a #}
|
||||
{# `leave_url`; the owner's pages never do, so their menu is unchanged. #}
|
||||
{% if leave_url %}
|
||||
<form method="POST" action="{{ leave_url }}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" id="id_my_sea_bye_btn" class="btn btn-abandon">BYE</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "apps/applets/_partials/_gear.html" with menu_id="id_my_sea_menu" %}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{# Read-only render of the owner's draw for a my-sea spectator (Phase B of #}
|
||||
{# [[my-sea-invite-voice-blueprint]]). Mirrors `_applet-my-sea.html`'s slot #}
|
||||
{# markup off the same `latest_draw_slots` payload (`my_sea_slots`), but #}
|
||||
{# standalone + with NO interactive affordances (FLIP / DEL / AUTO DRAW). #}
|
||||
<div class="my-sea-scroll my-sea-visit-scroll">
|
||||
{% for slot in my_sea_slots %}
|
||||
{% if slot.card %}
|
||||
<div class="my-sea-slot-wrap">
|
||||
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}{% if slot.card.deck_variant.has_card_images %} my-sea-slot--image{% endif %}"
|
||||
data-position="{{ slot.position }}"
|
||||
data-card-id="{{ slot.card.id }}"
|
||||
data-arcana-key="{{ slot.card.arcana }}">
|
||||
{% if slot.card.deck_variant.has_card_images %}
|
||||
<img class="sig-stage-card-img" src="{{ slot.card.image_url }}" alt="{{ slot.card.name }}">
|
||||
{% else %}
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{% if slot.face.qualifier_first %}
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
{% endif %}
|
||||
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="my-sea-slot-label">{{ slot.label }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="my-sea-slot-wrap">
|
||||
<div class="my-sea-slot my-sea-slot--empty" data-position="{{ slot.position }}"></div>
|
||||
<span class="my-sea-slot-label my-sea-slot-label--empty">{{ slot.label }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
{# semantics clean. `.position-status-icon` + #}
|
||||
{# `.fa-ban` are unchanged — already role- #}
|
||||
{# agnostic in _room.scss. #}
|
||||
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}">
|
||||
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}"{% if n == '1' and hand_non_empty %} data-seat-token="owner-{{ request.user.id }}-{{ active_draw.id }}"{% endif %}>
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">{{ n }}C</span>
|
||||
<i class="position-status-icon fa-solid {% if n == '1' and hand_non_empty %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
||||
@@ -964,6 +964,10 @@
|
||||
</script>
|
||||
|
||||
<script src="{% static 'apps/epic/room.js' %}"></script>
|
||||
{# Shared one-shot "seated" glow module — also drives seat 2C on #}
|
||||
{# the spectator page. Exposes window.playSeatGlow + auto-plays on #}
|
||||
{# load for any server-rendered .seated[data-seat-token] seat. #}
|
||||
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var page = document.querySelector('.my-sea-page');
|
||||
@@ -994,6 +998,11 @@
|
||||
statusIcon.classList.remove('fa-ban');
|
||||
statusIcon.classList.add('fa-circle-check');
|
||||
}
|
||||
// First-draw moment → play the one-shot seated flare
|
||||
// (same module the spectator page + load-time handler
|
||||
// use). On a later reload the server-rendered token
|
||||
// gates a repeat via localStorage.
|
||||
if (window.playSeatGlow) window.playSeatGlow(seat1);
|
||||
}
|
||||
setTimeout(function () {
|
||||
page.setAttribute('data-phase', 'picker');
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
{# their email prefix). #}
|
||||
<div class="gate-title-panel">
|
||||
<header class="gate-header">
|
||||
<h1>{{ user|at_handle }}'s Sea</h1>
|
||||
{# Spectator gate titles the OWNER's Sea (the visitor is #}
|
||||
{# depositing into someone else's table); owner gate #}
|
||||
{# titles their own. #}
|
||||
<h1>{% if spectator %}{{ owner|at_handle }}{% else %}{{ user|at_handle }}{% endif %}'s Sea</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +43,7 @@
|
||||
<div class="gate-main-panel">
|
||||
<div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}">
|
||||
{% if not deposit_reserved %}
|
||||
<form method="POST" action="{% url 'my_sea_insert_token' %}" style="display:contents">
|
||||
<form method="POST" action="{% if spectator %}{% url 'my_sea_visit_insert_token' visit_owner_id %}{% else %}{% url 'my_sea_insert_token' %}{% endif %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="token-rails" aria-label="Insert token to play">
|
||||
<span class="rail"></span>
|
||||
@@ -87,12 +90,19 @@
|
||||
{# + gear-btn NVM-only menu. Both render outside .gate-modal so the #}
|
||||
{# bud-btn lives at viewport fixed position (per `_bud.scss`) and #}
|
||||
{# the gear-btn sits atop `#id_kit_btn` in the bottom-right corner. #}
|
||||
{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}
|
||||
{# Gatekeeper NVM nav-backs to the my-sea TABLE HEX (landing) so the #}
|
||||
{# user lands one step back from the gatekeeper rather than ejecting #}
|
||||
{# all the way to /gameboard/. #}
|
||||
{% url 'my_sea' as nvm_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
|
||||
{# Bud invite is owner-only — a spectator doesn't invite others into #}
|
||||
{# someone else's Sea. #}
|
||||
{% if not spectator %}{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}{% endif %}
|
||||
{# NVM nav-backs to the my-sea TABLE HEX one step back. For a spectator #}
|
||||
{# that's the owner's visit landing + a BYE drops presence entirely. #}
|
||||
{% if spectator %}
|
||||
{% url 'my_sea_visit' visit_owner_id as nvm_url %}
|
||||
{% url 'my_sea_visit_leave' visit_owner_id as leave_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" with nvm_url=nvm_url leave_url=leave_url %}
|
||||
{% else %}
|
||||
{% url 'my_sea' as nvm_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
|
||||
{% endif %}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
106
src/templates/apps/gameboard/my_sea_visit.html
Normal file
106
src/templates/apps/gameboard/my_sea_visit.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Game Sea{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{# Phase B spectator surface — an ACCEPTED invitee watches {{ owner|at_handle }}'s #}
|
||||
{# Sea read-only. The owner's my_sea.html is left untouched; this is a #}
|
||||
{# dedicated, simpler template: the table hex (seat 1C = owner, 2C = this #}
|
||||
{# visitor) + the owner's draw rendered read-only (no FLIP / DEL / AUTO #}
|
||||
{# DRAW). GATE VIEW opens the visitor gate; once a token is deposited the #}
|
||||
{# center btn becomes VIEW DRAW (toggles the read-only draw into view). #}
|
||||
<div class="my-sea-page my-sea-visit-page"
|
||||
data-phase="landing"
|
||||
data-spectator="true"
|
||||
data-polarity="{% if significator_reversed %}levity{% else %}gravity{% endif %}">
|
||||
|
||||
<div class="my-sea-landing my-sea-visit-landing">
|
||||
<div class="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
<div class="room-table-scene">
|
||||
<div class="table-hex-border">
|
||||
<div class="table-hex">
|
||||
<div class="table-center">
|
||||
{% if seat2_present %}
|
||||
{# Visitor is present — VIEW DRAW reveals the owner's #}
|
||||
{# read-only draw (client-side toggle below). #}
|
||||
<button id="id_my_sea_view_draw_btn" type="button"
|
||||
class="btn btn-primary">VIEW<br>DRAW</button>
|
||||
{% else %}
|
||||
{# Not yet present — GATE VIEW → visitor token gate. #}
|
||||
<button id="id_my_sea_gate_view_btn" type="button"
|
||||
class="btn btn-primary"
|
||||
data-gate-url="{% url 'my_sea_visit_gate' owner.id %}">GATE<br>VIEW</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for n in "123456" %}
|
||||
{% if n == '1' and seat1_present %}
|
||||
<div class="table-seat seated" data-slot="1"
|
||||
data-seat-token="owner-{{ owner.id }}-{{ owner_draw_id }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">1C</span>
|
||||
<i class="position-status-icon fa-solid fa-circle-check"></i>
|
||||
</div>
|
||||
{% elif n == '2' and seat2_present %}
|
||||
<div class="table-seat seated" data-slot="2"
|
||||
data-seat-token="visit-{{ sea_invite.id }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">2C</span>
|
||||
<i class="position-status-icon fa-solid fa-circle-check"></i>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-seat" data-slot="{{ n }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">{{ n }}C</span>
|
||||
<i class="position-status-icon fa-solid fa-ban"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if seat2_present %}
|
||||
{# Owner's draw, read-only. Hidden until VIEW DRAW toggles it in. #}
|
||||
<div id="id_my_sea_visit_draw" class="my-sea-visit-draw" style="display:none">
|
||||
{% include "apps/gameboard/_partials/_my_sea_readonly_draw.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Gear menu — NVM (back to /gameboard/) + BYE (drop presence, free 2C). #}
|
||||
{% url 'my_sea_visit_leave' owner.id as leave_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" with leave_url=leave_url %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
// VIEW DRAW toggles the read-only draw against the table hex.
|
||||
var viewBtn = document.getElementById('id_my_sea_view_draw_btn');
|
||||
var draw = document.getElementById('id_my_sea_visit_draw');
|
||||
var landing = document.querySelector('.my-sea-visit-landing');
|
||||
if (viewBtn && draw) {
|
||||
viewBtn.addEventListener('click', function () {
|
||||
var showing = draw.style.display !== 'none';
|
||||
draw.style.display = showing ? 'none' : '';
|
||||
if (landing) landing.style.display = showing ? '' : 'none';
|
||||
});
|
||||
}
|
||||
// GATE VIEW → visitor gate.
|
||||
var gateBtn = document.getElementById('id_my_sea_gate_view_btn');
|
||||
if (gateBtn) {
|
||||
gateBtn.addEventListener('click', function () {
|
||||
window.location.href = gateBtn.dataset.gateUrl || '#';
|
||||
});
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
{% endblock scripts %}
|
||||
Reference in New Issue
Block a user