Compare commits
7 Commits
c426ca69fa
...
22d0507c3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d0507c3f | ||
|
|
419e022140 | ||
|
|
4010e452a6 | ||
|
|
72fefe2fc7 | ||
|
|
47871b5b4a | ||
|
|
ad9f7b43ed | ||
|
|
3ab60c67b6 |
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0 on 2026-05-09 04:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0006_alter_line_options'),
|
||||
('epic', '0008_blades_reversal_fickle'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='brief',
|
||||
name='room',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='epic.room'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='brief',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite')], default='user_post', max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='brief',
|
||||
name='post',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post'),
|
||||
),
|
||||
]
|
||||
@@ -94,10 +94,12 @@ class Brief(models.Model):
|
||||
KIND_NOTE_UNLOCK = "note_unlock"
|
||||
KIND_USER_POST = "user_post"
|
||||
KIND_SHARE_INVITE = "share_invite"
|
||||
KIND_GAME_INVITE = "game_invite"
|
||||
KIND_CHOICES = [
|
||||
(KIND_NOTE_UNLOCK, "Note unlock"),
|
||||
(KIND_USER_POST, "User post"),
|
||||
(KIND_SHARE_INVITE, "Share invite"),
|
||||
(KIND_GAME_INVITE, "Game invite"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
@@ -106,10 +108,25 @@ class Brief(models.Model):
|
||||
related_name="briefs",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
# Post is nullable now: KIND_GAME_INVITE briefs ride on a Room FK
|
||||
# instead of a Post (the gatekeeper invite confirmation has no post
|
||||
# to navigate to). Post FKs only set for note_unlock / user_post /
|
||||
# share_invite kinds.
|
||||
post = models.ForeignKey(
|
||||
Post,
|
||||
related_name="briefs",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
# Room FK — set only on KIND_GAME_INVITE briefs; FYI navigates to
|
||||
# the gatekeeper page for that room.
|
||||
room = models.ForeignKey(
|
||||
"epic.Room",
|
||||
related_name="briefs",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
# Line is nullable because a share_invite-style Brief can race ahead of its
|
||||
# async-appended Line write; the post FK alone is enough to navigate.
|
||||
@@ -142,16 +159,23 @@ class Brief(models.Model):
|
||||
"""Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind
|
||||
carries a square_url pointing at /billboard/my-notes/ so the
|
||||
thumbnail-square inside the banner jumps direct to the user's Note
|
||||
collection — other kinds get an empty square_url."""
|
||||
collection. GAME_INVITE kind has no Post — the FYI link navigates
|
||||
to the gatekeeper page for the brief's Room instead."""
|
||||
square_url = ""
|
||||
if self.kind == self.KIND_NOTE_UNLOCK:
|
||||
square_url = reverse("billboard:my_notes")
|
||||
if self.post_id:
|
||||
post_url = self.post.get_absolute_url()
|
||||
elif self.room_id:
|
||||
post_url = reverse("epic:gatekeeper", args=[self.room_id])
|
||||
else:
|
||||
post_url = ""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"kind": self.kind,
|
||||
"title": self.title,
|
||||
"line_text": self.line.text if self.line else "",
|
||||
"post_url": self.post.get_absolute_url(),
|
||||
"post_url": post_url,
|
||||
"square_url": square_url,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ class BriefModelTest(TestCase):
|
||||
b = Brief.objects.create(owner=self.user, post=self.post)
|
||||
self.assertIsNone(b.line)
|
||||
|
||||
def test_brief_owner_post_required(self):
|
||||
"""Brief without owner OR post is invalid; both are the load-bearing
|
||||
FKs (owner = whose attention; post = where FYI navigates)."""
|
||||
def test_brief_owner_required(self):
|
||||
"""Brief without owner is invalid (load-bearing for "whose
|
||||
attention"). Post used to be required too, but became nullable
|
||||
when GAME_INVITE briefs landed (those use Brief.room instead of
|
||||
Brief.post). The view layer enforces "post XOR room" per kind."""
|
||||
from django.db import IntegrityError, transaction
|
||||
with transaction.atomic(), self.assertRaises(IntegrityError):
|
||||
Brief.objects.create(post=self.post, line=self.line)
|
||||
with transaction.atomic(), self.assertRaises(IntegrityError):
|
||||
Brief.objects.create(owner=self.user, line=self.line)
|
||||
|
||||
def test_brief_carries_title(self):
|
||||
b = Brief.objects.create(
|
||||
|
||||
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""ITs for post.html invitee-vs-owner header rendering.
|
||||
|
||||
The "just me, @owner the {title}" / "shared between … & me, @owner …" lines
|
||||
were owner-centric (the legacy phrasing assumed the viewer is the post
|
||||
creator). For an invitee (a user in post.shared_with), that prose is
|
||||
confusing. This view branches the .post-header block:
|
||||
|
||||
• Owner viewing → unchanged existing prose.
|
||||
• Invitee viewing (sole) → "shared with me, @viewer the {title}" +
|
||||
"created by @owner the {owner_title}".
|
||||
• Invitee viewing (multi) → "shared with {other_recipients ...}" +
|
||||
"& me, @viewer the {title}" +
|
||||
"created by @owner the {owner_title}".
|
||||
|
||||
The view layer adds `viewer_is_owner` + `other_recipients` to the
|
||||
context; template branches on those.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.billboard.models import Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class PostInviteeViewContextTest(TestCase):
|
||||
"""Context vars: viewer_is_owner + other_recipients."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||
self.post = Post.objects.create(owner=self.owner, title="Coolio")
|
||||
Line.objects.create(post=self.post, text="seed", author=self.owner)
|
||||
|
||||
def test_owner_viewing_sets_viewer_is_owner_true(self):
|
||||
self.client.force_login(self.owner)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
self.assertTrue(response.context["viewer_is_owner"])
|
||||
|
||||
def test_invitee_viewing_sets_viewer_is_owner_false(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
self.assertFalse(response.context["viewer_is_owner"])
|
||||
|
||||
def test_other_recipients_excludes_viewer(self):
|
||||
"""For an invitee, other_recipients = shared_with minus self."""
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
others = list(response.context["other_recipients"])
|
||||
self.assertIn(self.bob, others)
|
||||
self.assertNotIn(self.alice, others)
|
||||
|
||||
def test_other_recipients_empty_for_sole_invitee(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
self.assertEqual(list(response.context["other_recipients"]), [])
|
||||
|
||||
def test_other_recipients_for_owner_is_full_shared_with(self):
|
||||
"""Owner viewing: other_recipients includes everyone (no self exclusion)."""
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.owner)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
others = list(response.context["other_recipients"])
|
||||
self.assertIn(self.alice, others)
|
||||
self.assertIn(self.bob, others)
|
||||
|
||||
|
||||
class PostInviteeViewTemplateTest(TestCase):
|
||||
"""Template prose: invitee branch shows "shared with me, …" /
|
||||
"created by @owner …" — does NOT show the owner-centric "just me" or
|
||||
"shared between"."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||
self.post = Post.objects.create(owner=self.owner, title="Coolio")
|
||||
Line.objects.create(post=self.post, text="seed", author=self.owner)
|
||||
|
||||
def test_sole_invitee_sees_shared_with_me(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
body = response.content.decode()
|
||||
self.assertIn("shared with me", body)
|
||||
self.assertIn("@alice", body)
|
||||
|
||||
def test_sole_invitee_does_not_see_just_me_or_shared_between(self):
|
||||
"""Scope to .post-header — the bud-panel JS includes 'just me,' as
|
||||
a regex literal in inline script, so a body-wide string match
|
||||
false-positives."""
|
||||
import lxml.html
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
tree = lxml.html.fromstring(body)
|
||||
header_text = tree.cssselect(".post-header")[0].text_content()
|
||||
self.assertNotIn("just me,", header_text)
|
||||
self.assertNotIn("shared between", header_text)
|
||||
|
||||
def test_invitee_sees_created_by_owner(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("created by", body)
|
||||
self.assertIn("@owner", body)
|
||||
|
||||
def test_multi_invitee_sees_shared_with_others_then_amp_me(self):
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("shared with", body)
|
||||
self.assertIn("@bob", body)
|
||||
self.assertIn("& me", body)
|
||||
self.assertIn("@alice", body)
|
||||
|
||||
def test_multi_invitee_does_not_see_self_in_recipients_line(self):
|
||||
"""The recipients line lists OTHER invitees, not self."""
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
# Coarse check: "shared with @bob" appears w/o @alice in same line
|
||||
# (since alice's "@alice" is on the "& me" line below). The full
|
||||
# body contains both, but the .post-shared-recipients line should
|
||||
# only list other_recipients (i.e., bob, not alice).
|
||||
# Use a narrower lxml-style assertion.
|
||||
import lxml.html
|
||||
tree = lxml.html.fromstring(body)
|
||||
recipients_p = tree.cssselect(".post-shared-recipients")
|
||||
self.assertEqual(len(recipients_p), 1)
|
||||
rec_text = recipients_p[0].text_content()
|
||||
self.assertIn("@bob", rec_text)
|
||||
self.assertNotIn("@alice", rec_text)
|
||||
|
||||
def test_owner_view_unchanged_when_recipients_present(self):
|
||||
"""Owner sees 'shared between' (old behavior)."""
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.owner)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("shared between", body)
|
||||
self.assertIn("& me", body)
|
||||
self.assertNotIn("created by", body)
|
||||
|
||||
def test_owner_view_just_me_when_no_recipients(self):
|
||||
"""Owner with no recipients: 'just me, …' (old behavior)."""
|
||||
self.client.force_login(self.owner)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("just me,", body)
|
||||
self.assertNotIn("created by", body)
|
||||
@@ -285,9 +285,31 @@ def view_post(request, post_id):
|
||||
Brief.objects.filter(
|
||||
owner=request.user, post=our_post, is_unread=True,
|
||||
).update(is_unread=False)
|
||||
|
||||
# Header-prose branching: post.html shows different self/shared lines
|
||||
# depending on whether the viewer IS the owner. The invitee branch
|
||||
# ("shared with me, @viewer …" + "created by @owner …") only kicks
|
||||
# in when (a) the viewer is authenticated AND (b) the post has an
|
||||
# owner AND (c) the viewer is NOT that owner. Ownerless posts and
|
||||
# anonymous viewers fall through to the owner-style rendering (which
|
||||
# handles missing data gracefully via the at_handle/display_name
|
||||
# filter guards).
|
||||
is_real_invitee = (
|
||||
request.user.is_authenticated
|
||||
and our_post.owner is not None
|
||||
and request.user != our_post.owner
|
||||
)
|
||||
viewer_is_owner = not is_real_invitee
|
||||
if is_real_invitee:
|
||||
other_recipients = our_post.shared_with.exclude(pk=request.user.pk)
|
||||
else:
|
||||
other_recipients = our_post.shared_with.all()
|
||||
|
||||
return render(request, "apps/billboard/post.html", {
|
||||
"post": our_post,
|
||||
"form": form,
|
||||
"viewer_is_owner": viewer_is_owner,
|
||||
"other_recipients": other_recipients,
|
||||
"page_class": "page-billpost",
|
||||
})
|
||||
|
||||
|
||||
143
src/apps/epic/tests/integrated/test_invite_gamer.py
Normal file
143
src/apps/epic/tests/integrated/test_invite_gamer.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""ITs for the gatekeeper invite_gamer view post-bud-btn refactor.
|
||||
|
||||
The legacy form-submit path (POST `invitee_email` + 302 redirect) still
|
||||
works for any pre-existing caller; the new bud-btn slide-out POSTs
|
||||
`recipient` (email OR username) w. Accept: application/json and gets
|
||||
back {brief, recipient_display}. On registered recipients we auto-add
|
||||
both directions of the buds graph (mirrors share_post per the Phase 2
|
||||
spec) and spawn a Brief w. kind=GAME_INVITE pointing at the room.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.billboard.models import Brief
|
||||
from apps.epic.models import Room, RoomInvite
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class InviteGamerAjaxTest(TestCase):
|
||||
"""Bud-btn flow: POST /room/<id>/gate/invite w. Accept: application/json."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.client.force_login(self.owner)
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
|
||||
|
||||
def _invite(self, recipient):
|
||||
return self.client.post(
|
||||
reverse("epic:invite_gamer", args=[self.room.id]),
|
||||
data={"recipient": recipient},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
def test_ajax_invite_returns_brief_payload(self):
|
||||
response = self._invite("alice@test.io")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertIn("brief", body)
|
||||
self.assertIn("recipient_display", body)
|
||||
self.assertIsNotNone(body["brief"])
|
||||
|
||||
def test_ajax_invite_creates_room_invite(self):
|
||||
self._invite("alice@test.io")
|
||||
self.assertTrue(RoomInvite.objects.filter(
|
||||
room=self.room, inviter=self.owner, invitee_email="alice@test.io",
|
||||
).exists())
|
||||
|
||||
def test_ajax_invite_resolves_username_to_email(self):
|
||||
"""Username-typed recipient stores the resolved User's email."""
|
||||
self._invite("alice")
|
||||
self.assertTrue(RoomInvite.objects.filter(
|
||||
room=self.room, invitee_email="alice@test.io",
|
||||
).exists())
|
||||
|
||||
def test_ajax_invite_creates_brief_with_game_invite_kind_and_room_fk(self):
|
||||
self._invite("alice@test.io")
|
||||
brief = Brief.objects.get(owner=self.owner)
|
||||
self.assertEqual(brief.kind, Brief.KIND_GAME_INVITE)
|
||||
self.assertEqual(brief.room, self.room)
|
||||
self.assertIsNone(brief.post)
|
||||
self.assertTrue(brief.is_unread)
|
||||
|
||||
def test_ajax_invite_brief_banner_dict_links_to_room_gatekeeper(self):
|
||||
body = self._invite("alice@test.io").json()
|
||||
self.assertEqual(
|
||||
body["brief"]["post_url"],
|
||||
reverse("epic:gatekeeper", args=[self.room.id]),
|
||||
)
|
||||
|
||||
def test_ajax_invite_auto_adds_recipient_to_inviter_buds(self):
|
||||
self._invite("alice@test.io")
|
||||
self.assertIn(self.alice, self.owner.buds.all())
|
||||
|
||||
def test_ajax_invite_auto_adds_inviter_to_recipient_buds_symmetric(self):
|
||||
"""Per Phase 2 spec: shared events imply mutual buds graph link."""
|
||||
self._invite("alice@test.io")
|
||||
self.assertIn(self.owner, self.alice.buds.all())
|
||||
|
||||
def test_ajax_invite_unregistered_email_creates_invite_no_buds_add(self):
|
||||
"""Privacy + correctness: unregistered recipient still gets a
|
||||
RoomInvite (so they can accept after registration), but the
|
||||
inviter's buds list isn't touched (we don't auto-add a non-User)."""
|
||||
response = self._invite("ghost@test.io")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(RoomInvite.objects.filter(
|
||||
room=self.room, invitee_email="ghost@test.io",
|
||||
).exists())
|
||||
self.assertEqual(self.owner.buds.count(), 0)
|
||||
|
||||
def test_ajax_invite_self_is_silent_noop(self):
|
||||
"""Inviting yourself: brief=null, no RoomInvite, no buds touch."""
|
||||
response = self._invite("owner@test.io")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["brief"])
|
||||
self.assertFalse(RoomInvite.objects.filter(room=self.room).exists())
|
||||
|
||||
def test_ajax_invite_blank_recipient_is_silent_noop(self):
|
||||
response = self._invite("")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["brief"])
|
||||
self.assertFalse(RoomInvite.objects.filter(room=self.room).exists())
|
||||
|
||||
def test_ajax_invite_recipient_display_is_username_when_registered(self):
|
||||
body = self._invite("alice@test.io").json()
|
||||
self.assertEqual(body["recipient_display"], "alice")
|
||||
|
||||
def test_ajax_invite_recipient_display_is_null_when_unregistered(self):
|
||||
body = self._invite("ghost@test.io").json()
|
||||
self.assertIsNone(body["recipient_display"])
|
||||
|
||||
|
||||
class InviteGamerLegacyFormTest(TestCase):
|
||||
"""Form-submit path (no Accept: application/json) preserved — older
|
||||
callers still get a 302 redirect to the gatekeeper. The legacy
|
||||
`invitee_email` field name is also still accepted for full backwards
|
||||
compat, even though the new bud-btn uses `recipient`."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io")
|
||||
self.client.force_login(self.owner)
|
||||
self.room = Room.objects.create(name="Test", owner=self.owner)
|
||||
|
||||
def test_form_submit_with_invitee_email_redirects(self):
|
||||
response = self.client.post(
|
||||
reverse("epic:invite_gamer", args=[self.room.id]),
|
||||
data={"invitee_email": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response["Location"],
|
||||
reverse("epic:gatekeeper", args=[self.room.id]),
|
||||
)
|
||||
|
||||
def test_form_submit_with_recipient_field_also_works(self):
|
||||
"""The bud-btn field name (recipient) also works on form submit."""
|
||||
response = self.client.post(
|
||||
reverse("epic:invite_gamer", args=[self.room.id]),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(RoomInvite.objects.filter(
|
||||
room=self.room, invitee_email="alice@test.io",
|
||||
).exists())
|
||||
@@ -656,16 +656,81 @@ def pick_roles(request, room_id):
|
||||
|
||||
@login_required
|
||||
def invite_gamer(request, room_id):
|
||||
if request.method == "POST":
|
||||
room = Room.objects.get(id=room_id)
|
||||
email = request.POST.get("invitee_email", "").strip()
|
||||
if email:
|
||||
RoomInvite.objects.get_or_create(
|
||||
room=room,
|
||||
inviter=request.user,
|
||||
invitee_email=email,
|
||||
defaults={"status": RoomInvite.PENDING}
|
||||
)
|
||||
"""Gatekeeper invite flow. Backwards-compatible w. the legacy
|
||||
`invitee_email` form-submit (still POSTs from any old caller); also
|
||||
serves the new bud-btn slide-out which sends `recipient` (email OR
|
||||
username) + Accept: application/json. Bud-btn flow:
|
||||
• Resolves recipient via _resolve_recipient (registered → User; else None).
|
||||
• Stores RoomInvite using the resolved email (or raw input if unregistered).
|
||||
• Auto-adds inviter ↔ recipient to each others' buds (symmetric, per
|
||||
share_post precedent — registered recipients only).
|
||||
• Spawns a Brief w. kind=GAME_INVITE + room=room (post=null).
|
||||
• Returns JSON {brief, recipient_display} when Accept matches; else
|
||||
redirects to gatekeeper as before."""
|
||||
if request.method != "POST":
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
from apps.billboard.models import Brief
|
||||
from apps.billboard.views import _resolve_recipient
|
||||
|
||||
room = Room.objects.get(id=room_id)
|
||||
is_ajax = "application/json" in request.headers.get("Accept", "")
|
||||
|
||||
# New bud-btn field name is `recipient`; legacy form uses `invitee_email`.
|
||||
raw = (
|
||||
request.POST.get("recipient")
|
||||
or request.POST.get("invitee_email")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not raw:
|
||||
if is_ajax:
|
||||
return JsonResponse({"brief": None, "recipient_display": None})
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
candidate = _resolve_recipient(raw)
|
||||
is_self = candidate is not None and candidate == request.user
|
||||
if is_self:
|
||||
if is_ajax:
|
||||
return JsonResponse({"brief": None, "recipient_display": None})
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
# RoomInvite uses the resolved User's email when available (so a
|
||||
# username-typed invite doesn't store the raw username as if it were
|
||||
# an email); falls back to the raw input for unregistered addresses.
|
||||
invitee_email = candidate.email if candidate else raw
|
||||
RoomInvite.objects.get_or_create(
|
||||
room=room,
|
||||
inviter=request.user,
|
||||
invitee_email=invitee_email,
|
||||
defaults={"status": RoomInvite.PENDING},
|
||||
)
|
||||
|
||||
# Buds graph: symmetric auto-add on registered recipients (mirrors
|
||||
# share_post). Idempotent on M2M; no-op on unregistered recipients.
|
||||
if candidate is not None:
|
||||
request.user.buds.add(candidate)
|
||||
candidate.buds.add(request.user)
|
||||
|
||||
# Brief: confirmation banner for the inviter. Brief.post stays null;
|
||||
# banner FYI navigates to the room's gatekeeper page via Brief.room.
|
||||
brief = Brief.objects.create(
|
||||
owner=request.user,
|
||||
post=None,
|
||||
room=room,
|
||||
kind=Brief.KIND_GAME_INVITE,
|
||||
title="Invite sent",
|
||||
)
|
||||
|
||||
if is_ajax:
|
||||
recipient_display = None
|
||||
if candidate is not None:
|
||||
recipient_display = candidate.username or candidate.email
|
||||
return JsonResponse({
|
||||
"brief": brief.to_banner_dict(),
|
||||
"recipient_display": recipient_display,
|
||||
})
|
||||
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,11 @@ def relative_ts(dt):
|
||||
|
||||
@register.filter
|
||||
def display_name(user):
|
||||
if user is None:
|
||||
# `getattr` guards: AnonymousUser has no `.email` attribute and is not
|
||||
# None, so `if user is None` doesn't catch it. Render anonymous viewers
|
||||
# as empty rather than crashing — the legacy ownerless-post path
|
||||
# (Percival ch. 18) shows post detail to anon visitors.
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return ""
|
||||
if user.username:
|
||||
return user.username
|
||||
@@ -55,8 +59,9 @@ def at_handle(user):
|
||||
"""`@username` when the user has set one; falls back to the truncated
|
||||
email otherwise (no `@` prefix on bare emails since the address itself
|
||||
already carries the `@`). Used in post.html to colour usernames in the
|
||||
--quaUser palette key while leaving emails as-is."""
|
||||
if user is None:
|
||||
--quaUser palette key while leaving emails as-is. Anonymous viewers
|
||||
render as empty (legacy ownerless-post path)."""
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return ""
|
||||
if user.username:
|
||||
return f"@{user.username}"
|
||||
|
||||
116
src/functional_tests/test_gatekeeper_bud_btn.py
Normal file
116
src/functional_tests/test_gatekeeper_bud_btn.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""FT for the gatekeeper invite via #id_bud_btn slide-out.
|
||||
|
||||
Replaces the legacy inline `<form action="invite_gamer">` panel inside
|
||||
the gatekeeper modal. The bud-btn lives at the upper-right corner of
|
||||
the right sidebar (footer in landscape); slide-out hosts the email/
|
||||
username field + OK btn. Submit fires async POST to
|
||||
epic:invite_gamer w. Accept: application/json — server returns
|
||||
{brief, recipient_display}, JS shows the slide-down Brief banner.
|
||||
"""
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.billboard.models import Brief
|
||||
from apps.epic.models import Room, RoomInvite
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import FunctionalTest
|
||||
|
||||
|
||||
class GatekeeperBudBtnPresenceTest(FunctionalTest):
|
||||
"""The bud-btn renders for the room owner during gate phase, and is
|
||||
absent for non-owners (friend invites are owner-only)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.gamer = User.objects.create(email="gamer@test.io", username="gamer")
|
||||
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
|
||||
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
|
||||
|
||||
def test_bud_btn_renders_for_owner(self):
|
||||
self.create_pre_authenticated_session("owner@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
|
||||
def test_bud_btn_absent_for_non_owner(self):
|
||||
# A registered non-owner viewer doesn't see the invite affordance.
|
||||
self.create_pre_authenticated_session("gamer@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
# Gatekeeper-specific element confirms page rendered
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
|
||||
self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
|
||||
|
||||
def test_legacy_invite_email_input_is_gone(self):
|
||||
"""Sanity: the old inline form has been removed."""
|
||||
self.create_pre_authenticated_session("owner@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
|
||||
self.assertFalse(self.browser.find_elements(By.ID, "id_invite_email"))
|
||||
|
||||
|
||||
class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
|
||||
"""OK on the bud-btn slide-out fires the async invite — RoomInvite
|
||||
persisted, Brief w/ kind=GAME_INVITE created, slide-down banner shown."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
|
||||
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
|
||||
self.create_pre_authenticated_session("owner@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
|
||||
def _open_panel_and_invite(self, recipient):
|
||||
bud_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
bud_btn.click()
|
||||
recipient_input = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_recipient")
|
||||
)
|
||||
recipient_input.send_keys(recipient)
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||||
).click()
|
||||
return bud_btn
|
||||
|
||||
def test_invite_creates_room_invite(self):
|
||||
self._open_panel_and_invite("alice@test.io")
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
RoomInvite.objects.filter(
|
||||
room=self.room, invitee_email="alice@test.io"
|
||||
).count(),
|
||||
1,
|
||||
))
|
||||
|
||||
def test_invite_spawns_game_invite_brief(self):
|
||||
self._open_panel_and_invite("alice@test.io")
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
Brief.objects.filter(
|
||||
owner=self.owner, kind=Brief.KIND_GAME_INVITE,
|
||||
).count(),
|
||||
1,
|
||||
))
|
||||
|
||||
def test_invite_renders_slide_down_banner(self):
|
||||
self._open_panel_and_invite("alice@test.io")
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
|
||||
|
||||
def test_invite_closes_panel_after_success(self):
|
||||
bud_btn = self._open_panel_and_invite("alice@test.io")
|
||||
self.wait_for(lambda: self.assertNotIn("active", bud_btn.get_attribute("class")))
|
||||
|
||||
def test_invite_username_resolves_to_user_email(self):
|
||||
"""Username-typed invite stores the resolved User's email."""
|
||||
self._open_panel_and_invite("alice")
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
RoomInvite.objects.filter(
|
||||
room=self.room, invitee_email="alice@test.io"
|
||||
).count(),
|
||||
1,
|
||||
))
|
||||
|
||||
def test_invite_auto_adds_recipient_to_owner_buds(self):
|
||||
self._open_panel_and_invite("alice@test.io")
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
self.alice, list(self.owner.buds.all())
|
||||
))
|
||||
@@ -127,12 +127,19 @@ class GatekeeperTest(FunctionalTest):
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
|
||||
)
|
||||
# 2. Founder invites friend
|
||||
invite_input = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_invite_email")
|
||||
# 2. Founder invites friend via the bud-btn slide-out (replaces
|
||||
# the legacy inline #id_invite_email form post-bud-btn refactor).
|
||||
bud_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_bud_btn")
|
||||
)
|
||||
invite_input.send_keys("friend@test.io")
|
||||
self.browser.find_element(By.ID, "id_invite_btn").click()
|
||||
bud_btn.click()
|
||||
recipient = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_recipient")
|
||||
)
|
||||
recipient.send_keys("friend@test.io")
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||||
).click()
|
||||
# 3. Friend logs in, sees invitation in My Games
|
||||
self.create_pre_authenticated_session("friend@test.io")
|
||||
self.browser.get(self.live_server_url + "/gameboard/")
|
||||
|
||||
@@ -112,11 +112,11 @@
|
||||
z-index: 312;
|
||||
}
|
||||
|
||||
// In landscape: shift gear btn and applet menus left of the footer right sidebar
|
||||
// XL override below doubles sidebar to 8rem — centre items in the wider column.
|
||||
// In landscape: shift gear btn and applet menus into the footer-sidebar
|
||||
// column. Both gear-btn (3rem wide) and the menus are centred in the
|
||||
// `var(--sidebar-w)` slot via `right: calc((var(--sidebar-w) - 3rem) / 2)`,
|
||||
// which scales with the rem-fluid root — no per-breakpoint override.
|
||||
@media (orientation: landscape) {
|
||||
$sidebar-w: 4rem;
|
||||
|
||||
.gameboard-page,
|
||||
.dashboard-page,
|
||||
.wallet-page,
|
||||
@@ -124,8 +124,8 @@
|
||||
.billboard-page,
|
||||
.billscroll-page {
|
||||
> .gear-btn {
|
||||
right: 1rem;
|
||||
bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
bottom: 3.95rem;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
@@ -137,32 +137,12 @@
|
||||
#id_room_menu,
|
||||
#id_billboard_applet_menu,
|
||||
#id_billscroll_menu {
|
||||
right: 1rem;
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
bottom: 6.6rem;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
// Centre gear btn and menus in the doubled 8rem sidebar (was 0.5rem from right edge)
|
||||
.gameboard-page,
|
||||
.dashboard-page,
|
||||
.wallet-page,
|
||||
.room-page,
|
||||
.billboard-page,
|
||||
.billscroll-page {
|
||||
> .gear-btn { right: 2.5rem; }
|
||||
}
|
||||
|
||||
#id_dash_applet_menu,
|
||||
#id_game_applet_menu,
|
||||
#id_game_kit_menu,
|
||||
#id_wallet_applet_menu,
|
||||
#id_room_menu,
|
||||
#id_billboard_applet_menu,
|
||||
#id_billscroll_menu { right: 2.5rem; }
|
||||
}
|
||||
|
||||
// ── Applet box visual shell (reusable outside the grid) ────
|
||||
%applet-box {
|
||||
border:
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
// ── Fluid root rem: 1rem scales with viewport ─────────────────────────────
|
||||
// All sidebar/h2/font sizes downstream are in rem, so redefining root
|
||||
// font-size against `vmin` (smaller of vh/vw) gives us a single sliding
|
||||
// scale that's invariant under phone rotation: rotating swaps width/height
|
||||
// but vmin stays the same → 1rem stays the same → navbar/footer/h2 hold
|
||||
// their size. Floor 14px on cramped viewports, ceiling 22px on huge ones.
|
||||
// 2.4vmin hits 16px (browser default) at vmin=667 (iPhone SE landscape).
|
||||
html {
|
||||
font-size: clamp(14px, 2.4vmin, 22px);
|
||||
}
|
||||
|
||||
// Layout custom properties — single source of truth for the landscape
|
||||
// sidebar width (navbar/footer) + the rotated-h2 column slot to the right
|
||||
// of the navbar. Container margin-left in landscape adds these so applets
|
||||
// can't bleed under the wordmark.
|
||||
:root {
|
||||
--sidebar-w: 5rem;
|
||||
--h2-col-w: 3rem;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -172,23 +192,43 @@ body {
|
||||
.col-lg-6 {
|
||||
max-width: inherit;
|
||||
margin: 0 1rem;
|
||||
|
||||
|
||||
// Two-span title: <span>BILL</span><span>POST</span>. First
|
||||
// word (always 4 letters: BILL/DASH/GAME/etc.) gets 45% of
|
||||
// the title width; the variable second word fills the
|
||||
// remaining 55%. Letters within each span spread via
|
||||
// text-align: justify + text-justify: inter-character. The
|
||||
// first-span colour shifts to --quaUser so the two-tone
|
||||
// heading reads "Bill | Post" / "Dash | Sky".
|
||||
h2 {
|
||||
display: flex;
|
||||
font-size: 3rem;
|
||||
color: rgba(var(--secUser), 0.75);
|
||||
margin-bottom: 1rem;
|
||||
text-align: justify;
|
||||
text-align-last: justify;
|
||||
text-justify: inter-character;
|
||||
text-transform: uppercase;
|
||||
text-shadow:
|
||||
// 1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left)
|
||||
var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
|
||||
var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8)
|
||||
;
|
||||
|
||||
span {
|
||||
> span {
|
||||
text-align: justify;
|
||||
text-align-last: justify;
|
||||
text-justify: inter-character;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
// Padding-inline (logical) creates the natural visual gap
|
||||
// between the two words at the 45/55 boundary — works for
|
||||
// both portrait (horizontal) AND the landscape rotated
|
||||
// wordmark (vertical-rl writing mode).
|
||||
> span:first-child {
|
||||
flex: 0 0 45%;
|
||||
padding-inline-end: 0.4em;
|
||||
color: rgba(var(--quaUser), 0.75);
|
||||
}
|
||||
> span:last-child {
|
||||
flex: 0 0 55%;
|
||||
padding-inline-start: 0.4em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,31 +241,20 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1100px) {
|
||||
body .container {
|
||||
.navbar {
|
||||
h1 {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
$sidebar-w: 5rem;
|
||||
|
||||
// ── Sidebar layout: navbar ← left, footer → right ────────────────────────────
|
||||
body {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
// Navbar → fixed left sidebar
|
||||
// Navbar → fixed left sidebar (width derives from --sidebar-w which is
|
||||
// fluid via the rem-redefine above; no per-breakpoint width jumps).
|
||||
body .container .navbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
width: $sidebar-w;
|
||||
width: var(--sidebar-w);
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: none;
|
||||
border-right: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||
@@ -292,8 +321,8 @@ body {
|
||||
|
||||
// Login form: offset from fixed sidebars in landscape
|
||||
.input-group {
|
||||
left: $sidebar-w;
|
||||
right: $sidebar-w;
|
||||
left: var(--sidebar-w);
|
||||
right: var(--sidebar-w);
|
||||
|
||||
.navbar-text {
|
||||
writing-mode: horizontal-tb;
|
||||
@@ -306,35 +335,47 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// Container: fill center, compensate for fixed sidebars on both sides.
|
||||
// max-width: none overrides the @media (min-width: 1200px) rule above so the
|
||||
// container fills all available space between the two sidebars on wide screens.
|
||||
// Container: fill center, compensate for fixed sidebars on both sides
|
||||
// AND for the rotated-h2 column on the left (so applets can't bleed
|
||||
// under the wordmark — true aperture clipping).
|
||||
// max-width: none overrides the @media (min-width: 1200px) rule above
|
||||
// so the container fills all available space between the sidebars.
|
||||
body .container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
margin-left: $sidebar-w;
|
||||
margin-right: $sidebar-w;
|
||||
margin-left: calc(var(--sidebar-w) + var(--h2-col-w));
|
||||
margin-right: var(--sidebar-w);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
// Header row: h2 rotates into the left gutter (just right of the navbar border).
|
||||
// position:fixed takes h2 out of flow; .row collapses to zero height automatically.
|
||||
// Header row: h2 rotates into the dedicated --h2-col-w slot just right
|
||||
// of the navbar. position:fixed takes h2 out of flow; .row collapses
|
||||
// to zero height automatically. Resets portrait flex so the rotated
|
||||
// wordmark renders as one continuous title (not split 45/55 here).
|
||||
body .container .row {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
body .container .row .col-lg-6 h2 {
|
||||
position: fixed;
|
||||
left: 5rem; // $sidebar-w — flush with the navbar right border
|
||||
left: var(--sidebar-w);
|
||||
width: var(--h2-col-w);
|
||||
top: 50%;
|
||||
height: 80vh; // explicit height so the flex 45/55 % basis resolves
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
writing-mode: vertical-rl;
|
||||
font-size: 1.5rem;
|
||||
writing-mode: vertical-rl; // inline axis becomes top-to-bottom; flex stacks on it
|
||||
font-size: 3rem; // rem-fluid → no min-height jumps
|
||||
letter-spacing: 0.4em;
|
||||
margin: 0;
|
||||
z-index: 85;
|
||||
pointer-events: none;
|
||||
// Inherits display: flex + the per-span flex 45/55 + padding-inline
|
||||
// boundary from the portrait base. With writing-mode: vertical-rl the
|
||||
// flex axis runs vertically, so first-span (BILL) takes 45% of the
|
||||
// height (becomes bottom 45% after rotate(180deg)) and second-span
|
||||
// (POST) takes the upper 55%. padding-inline-end resolves to the
|
||||
// bottom edge of the first span — natural break between words.
|
||||
}
|
||||
|
||||
// Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary)
|
||||
@@ -344,7 +385,7 @@ body {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: $sidebar-w;
|
||||
width: var(--sidebar-w);
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -371,14 +412,23 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// ©2026 Dis Co. — single-line vertical strip at the right edge of
|
||||
// the right sidebar (= the very right edge of the viewport in
|
||||
// landscape). Reads bottom-to-top via writing-mode: vertical-rl +
|
||||
// rotate(180deg) — same pattern as the navbar's rotated brand
|
||||
// wordmark. Tucks into the empty 0.875rem gutter between the
|
||||
// viewport edge and the centred icon/btn column, no overlap.
|
||||
.footer-container {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
text-align: center;
|
||||
line-height: 0.75 !important;
|
||||
right: 0.125rem;
|
||||
top: auto;
|
||||
line-height: 1 !important;
|
||||
color: rgba(var(--secUser), 1);
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
white-space: nowrap;
|
||||
|
||||
br { display: block; }
|
||||
br { display: none; }
|
||||
|
||||
small {
|
||||
font-size: 0.75rem !important;
|
||||
@@ -387,16 +437,11 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// Footer typography refinements that only kick in once the viewport is
|
||||
// wide enough to clear the cramped phone-landscape regime. Sidebar
|
||||
// dimensions themselves are now fluid via rem and don't need a per-
|
||||
// breakpoint width override (the old ≥1800px doubling block is gone).
|
||||
@media (orientation: landscape) and (min-width: 700px) {
|
||||
body .container .row .col-lg-6 h2 {
|
||||
@media (min-height: 400px) {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
@media (min-height: 500px) {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
body #id_footer {
|
||||
#id_footer_nav {
|
||||
gap: 3rem !important;
|
||||
@@ -411,7 +456,8 @@ body {
|
||||
|
||||
.footer-container {
|
||||
line-height: 1;
|
||||
margin-top: 0.5rem;
|
||||
// margin-top vestige of the absolute-top-anchored layout —
|
||||
// dropped now that the rotated text is bottom-anchored.
|
||||
|
||||
small {
|
||||
font-size: 1rem;
|
||||
@@ -420,61 +466,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// ── XL landscape (≥1800px): double sidebar widths and scale content ────────────
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
$sidebar-xl: 8rem;
|
||||
|
||||
body .container .navbar {
|
||||
width: $sidebar-xl;
|
||||
|
||||
.container-fluid {
|
||||
gap: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-brand h1 { font-size: 2.4rem; }
|
||||
.navbar-text { font-size: 0.78rem; } // 0.65rem × 1.2
|
||||
// .btn-primary { width: 4rem; height: 4rem; font-size: 0.875rem; }
|
||||
|
||||
.input-group {
|
||||
left: $sidebar-xl;
|
||||
right: $sidebar-xl;
|
||||
}
|
||||
}
|
||||
|
||||
body .container {
|
||||
margin-left: $sidebar-xl;
|
||||
margin-right: $sidebar-xl;
|
||||
}
|
||||
|
||||
// h2 page title: keep vertical rotation; shift left to clear the wider XL navbar.
|
||||
body .container .row .col-lg-6 h2 {
|
||||
left: 8rem; // $sidebar-xl
|
||||
@media (min-height: 800px) {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
body #id_footer {
|
||||
width: $sidebar-xl;
|
||||
|
||||
#id_footer_nav {
|
||||
gap: 8rem !important;
|
||||
a { font-size: 3rem; }
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
|
||||
small {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: portrait) and (max-width: 500px) {
|
||||
body .container {
|
||||
.navbar {
|
||||
|
||||
@@ -12,14 +12,15 @@
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
|
||||
// In landscape, the bud-btn moves to the UPPER-RIGHT corner of the
|
||||
// footer sidebar (top of the right sidebar). Centred within the
|
||||
// var(--sidebar-w) column. Mirrors kit-btn / gear-btn (bottom-right)
|
||||
// for the horizontal-centre formula, but anchors at top instead.
|
||||
@media (orientation: landscape) {
|
||||
left: 1rem;
|
||||
bottom: 0.5rem;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
left: 2.5rem; // mirror the doubled 8rem sidebar centring
|
||||
left: auto;
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
top: 0.5rem;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
z-index: 318;
|
||||
@@ -63,13 +64,15 @@
|
||||
opacity: 0;
|
||||
|
||||
@media (orientation: landscape) {
|
||||
left: calc(4rem + 0.5rem); // clear the navbar sidebar
|
||||
right: calc(4rem + 0.5rem); // clear the footer sidebar
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
left: calc(8rem + 0.5rem);
|
||||
right: calc(8rem + 0.5rem);
|
||||
// Bud-btn lives at the top of the right sidebar in landscape, so
|
||||
// the panel slides out leftward from the right edge along the top.
|
||||
// Clear both fixed sidebars; transform-origin flips to right so
|
||||
// the closed state collapses into the bud-btn.
|
||||
top: 0.5rem;
|
||||
bottom: auto;
|
||||
left: calc(var(--sidebar-w) + 0.5rem);
|
||||
right: calc(var(--sidebar-w) + 0.5rem);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
#id_recipient {
|
||||
@@ -122,8 +125,8 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
|
||||
// ── Bud autocomplete suggestions (mirror of sky-place birth picker) ──
|
||||
// Sibling of #id_bud_panel (which has overflow:hidden for the scaleX
|
||||
// slide animation, so the suggestions can't be a child or they'd clip).
|
||||
// Position-fixed above the panel; same left/right inset as the panel
|
||||
// at each breakpoint so the dropdown lines up.
|
||||
// Position-fixed; portrait sits ABOVE the panel (panel at bottom),
|
||||
// landscape sits BELOW the panel (panel at top of viewport).
|
||||
.bud-suggestions {
|
||||
position: fixed;
|
||||
bottom: 4rem; // panel bottom (0.5rem) + height (3rem) + gap (0.5rem)
|
||||
@@ -138,13 +141,12 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@media (orientation: landscape) {
|
||||
left: calc(4rem + 0.5rem);
|
||||
right: calc(4rem + 0.5rem);
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
left: calc(8rem + 0.5rem);
|
||||
right: calc(8rem + 0.5rem);
|
||||
// Panel now at top in landscape, so suggestions drop downward.
|
||||
top: 4rem;
|
||||
bottom: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
left: calc(var(--sidebar-w) + 0.5rem);
|
||||
right: calc(var(--sidebar-w) + 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
bottom: 0.5rem;
|
||||
right: 0.5rem;
|
||||
|
||||
// In landscape, centre the 3rem-wide btn within the var(--sidebar-w)
|
||||
// footer sidebar — matches the bud-btn (bottom-left) + gear-btn (above
|
||||
// kit-btn) formula. The clamp on the root rem means the sidebar scales
|
||||
// fluidly, so this single rule covers all viewports without a per-
|
||||
// breakpoint override.
|
||||
@media (orientation: landscape) {
|
||||
right: 1rem;
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
bottom: 0.5rem;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
right: 2.5rem; // centre in doubled 8rem sidebar
|
||||
}
|
||||
|
||||
z-index: 318;
|
||||
font-size: 1.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
130
src/templates/apps/billboard/_partials/_bud_invite_panel.html
Normal file
130
src/templates/apps/billboard/_partials/_bud_invite_panel.html
Normal file
@@ -0,0 +1,130 @@
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
{# _bud_invite_panel.html — bud btn + slide-out for the gatekeeper game- #}
|
||||
{# invite flow. Replaces the legacy `<form action="invite_gamer">` #}
|
||||
{# inline panel inside _gatekeeper.html. #}
|
||||
{# #}
|
||||
{# Differences from the post-share variant (_bud_panel.html): #}
|
||||
{# • POSTs to epic:invite_gamer instead of billboard:share_post. #}
|
||||
{# • Server returns {brief, recipient_display} — no line_text (no Post #}
|
||||
{# to append a Line to). JS just shows the Brief banner. #}
|
||||
{# #}
|
||||
{# Caller must pass `room` in context. #}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
|
||||
<button id="id_bud_btn" type="button" aria-label="Invite a friend">
|
||||
<i class="fa-solid fa-handshake"></i>
|
||||
</button>
|
||||
|
||||
<div id="id_bud_panel"
|
||||
data-invite-url="{% url 'epic:invite_gamer' room.id %}"
|
||||
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
|
||||
<input id="id_recipient"
|
||||
name="recipient"
|
||||
type="text"
|
||||
placeholder="friend@example.com or username"
|
||||
autocomplete="off">
|
||||
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
|
||||
</div>
|
||||
|
||||
{# Autocomplete suggestions — sibling because the panel has overflow:hidden #}
|
||||
{# for the slide-in scaleX animation. Pulls from request.user.buds. #}
|
||||
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
|
||||
|
||||
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
|
||||
<script>
|
||||
bindBudAutocomplete(
|
||||
document.getElementById('id_recipient'),
|
||||
document.getElementById('id_bud_suggestions'),
|
||||
{ searchUrl: '{% url "billboard:search_buds" %}' }
|
||||
);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var btn = document.getElementById('id_bud_btn');
|
||||
var panel = document.getElementById('id_bud_panel');
|
||||
var input = document.getElementById('id_recipient');
|
||||
var ok = document.getElementById('id_bud_ok');
|
||||
var html = document.documentElement;
|
||||
if (!btn || !panel || !input || !ok) return;
|
||||
|
||||
function _csrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function _open() {
|
||||
html.classList.add('bud-open');
|
||||
btn.classList.add('active');
|
||||
setTimeout(function () { input.focus(); }, 60);
|
||||
}
|
||||
|
||||
function _close(opts) {
|
||||
opts = opts || {};
|
||||
html.classList.remove('bud-open');
|
||||
btn.classList.remove('active');
|
||||
if (opts.clear !== false) input.value = '';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (html.classList.contains('bud-open')) {
|
||||
_close();
|
||||
} else {
|
||||
_open();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!html.classList.contains('bud-open')) return;
|
||||
if (panel.contains(e.target)) return;
|
||||
if (e.target === btn || btn.contains(e.target)) return;
|
||||
// Suggestions live outside the panel; clicking inside them must
|
||||
// NOT close+clear the panel.
|
||||
var sg = document.getElementById('id_bud_suggestions');
|
||||
if (sg && sg.contains(e.target)) return;
|
||||
_close();
|
||||
});
|
||||
|
||||
ok.addEventListener('click', function () {
|
||||
var recipient = input.value.trim();
|
||||
if (!recipient) return;
|
||||
|
||||
var fd = new FormData();
|
||||
fd.set('recipient', recipient);
|
||||
|
||||
fetch(panel.dataset.inviteUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': _csrf(),
|
||||
},
|
||||
body: fd,
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||
.then(function (data) {
|
||||
if (window.Brief && data.brief) Brief.showBanner(data.brief);
|
||||
_close({ clear: true });
|
||||
})
|
||||
.catch(function () {
|
||||
// Privacy-safe: even unregistered/self resolves to 200
|
||||
// {brief: null}; only network/5xx land here. Just close.
|
||||
});
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
ok.click();
|
||||
}
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block title_text %}Billboard{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>board{% endblock header_text %}
|
||||
{% block header_text %}<span>Bill</span><span>board</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="billboard-page">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Billbuds{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>buds{% endblock header_text %}
|
||||
{% block header_text %}<span>Bill</span><span>buds</span>{% endblock header_text %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Billnotes{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>notes{% endblock header_text %}
|
||||
{% block header_text %}<span>Bill</span><span>notes</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="note-page">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Billposts{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>posts{% endblock header_text %}
|
||||
{% block header_text %}<span>Bill</span><span>posts</span>{% endblock header_text %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Billpost{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>post{% endblock header_text %}
|
||||
{% block header_text %}<span>Bill</span><span>post</span>{% endblock header_text %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
@@ -14,14 +14,27 @@
|
||||
<div class="post-page">
|
||||
<header class="post-header">
|
||||
<h3 class="post-title">{{ post.title }}</h3>
|
||||
{% with recipients=post.shared_with.all %}
|
||||
{% if recipients %}
|
||||
<p class="post-shared-recipients">shared between {% for r in recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||
{% if viewer_is_owner %}
|
||||
{# Owner viewing — owner-centric prose. "shared between" lists #}
|
||||
{# every recipient; the self line is the owner's own handle. #}
|
||||
{% if other_recipients %}
|
||||
<p class="post-shared-recipients">shared between {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||
<p class="post-shared-self">& me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||
{% else %}
|
||||
<p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{# Invitee viewing — "shared with" prose centred on the viewer #}
|
||||
{# (request.user). Sole invitee collapses to a single line; the #}
|
||||
{# "created by …" line attributes the post to its founder. #}
|
||||
{% if other_recipients %}
|
||||
<p class="post-shared-recipients">shared with {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||
<p class="post-shared-self">& me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
|
||||
{% else %}
|
||||
<p class="post-shared-self">shared with me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
|
||||
{% endif %}
|
||||
<p class="post-created-by">created by <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<ul id="id_post_table" class="post-lines">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block title_text %}{{ room.name }} — Billscroll{% endblock %}
|
||||
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
|
||||
{% block header_text %}<span>Bill</span><span>scroll</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
{% block title_text %}Dashboard{% endblock title_text %}
|
||||
{% block header_text %}
|
||||
{% if user.is_authenticated %}
|
||||
<span>Dash</span>board
|
||||
<span>Dash</span><span>board</span>
|
||||
{% else %}
|
||||
<span>Howdy </span>stranger
|
||||
<span>Howdy </span><span>stranger</span>
|
||||
{% endif %}
|
||||
{% endblock header_text %}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}My Sky{% endblock title_text %}
|
||||
{% block header_text %}<span>Dash</span>sky{% endblock header_text %}
|
||||
{% block header_text %}<span>Dash</span><span>sky</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="sky-page"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Dashwallet{% endblock title_text %}
|
||||
{% block header_text %}<span>Dash</span>wallet{% endblock header_text %}
|
||||
{% block header_text %}<span>Dash</span><span>wallet</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="wallet-page">
|
||||
|
||||
@@ -59,16 +59,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.user == room.owner %}
|
||||
<div class="gate-invite-panel">
|
||||
<h3>Invite Friend</h3>
|
||||
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}" style="display:flex; gap:0.5rem; align-items:center;">
|
||||
{% csrf_token %}
|
||||
<input type="email" name="invitee_email" id="id_invite_email" class="form-control form-control-lg" placeholder="friend@example.com" style="flex:1; min-width:0;" hx-preserve>
|
||||
<button type="submit" id="id_invite_btn" class="btn btn-confirm">OK</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Legacy gate-invite-panel retired in favour of #id_bud_btn at #}
|
||||
{# the upper-right of the footer (room.html includes the bud #}
|
||||
{# invite panel partial when the viewer owns the room). #}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Game Kit{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span>Kit{% endblock header_text %}
|
||||
{% block header_text %}<span>Game</span><span>Kit</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="gameboard-page">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Gameboard{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span>board{% endblock header_text %}
|
||||
{% block header_text %}<span>Game</span><span>board</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="gameboard-page">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static tooltip_tags %}
|
||||
|
||||
{% block title_text %}Gameboard{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span>room{% endblock header_text %}
|
||||
{% block header_text %}<span>Game</span><span>room</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="room-page" data-room-id="{{ room.id }}"
|
||||
@@ -92,6 +92,12 @@
|
||||
{% endif %}
|
||||
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
|
||||
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
||||
{# Owner-only invite affordance: handshake btn at the upper-right #}
|
||||
{# of the right sidebar w. slide-out + autocomplete. Replaces the #}
|
||||
{# legacy inline `<form action="invite_gamer">` panel. #}
|
||||
{% if request.user == room.owner %}
|
||||
{% include "apps/billboard/_partials/_bud_invite_panel.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if room.table_status %}
|
||||
<div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" and starter_roles|length < 6 %} class="role-select-phase"{% endif %}>
|
||||
@@ -117,6 +123,9 @@
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
{# Brief module — needed by _bud_invite_panel's OK handler so the #}
|
||||
{# slide-down banner shows up on a successful gatekeeper invite. #}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/room.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/role-select.js' %}"></script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block title_text %}Tarot — {{ room.name }}{% endblock title_text %}
|
||||
{% block header_text %}<span>Tarot</span> — {{ room.name }}{% endblock header_text %}
|
||||
{% block header_text %}<span>Tarot</span><span> — {{ room.name }}</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tarot-page">
|
||||
|
||||
Reference in New Issue
Block a user