buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
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:
@@ -0,0 +1,40 @@
|
||||
"""Rename the My Buddies applet → My Buds (slug + name).
|
||||
|
||||
UI-vocabulary tightening — see lyric/0005_rename_buddies_to_buds for the
|
||||
parallel User.buddies → User.buds field rename. BILLBUDDIES overflowed
|
||||
the page-header band; BILLBUDS fits cleanly.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
try:
|
||||
applet = Applet.objects.get(slug="my-buddies")
|
||||
except Applet.DoesNotExist:
|
||||
return
|
||||
applet.slug = "my-buds"
|
||||
applet.name = "My Buds"
|
||||
applet.save(update_fields=["slug", "name"])
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
try:
|
||||
applet = Applet.objects.get(slug="my-buds")
|
||||
except Applet.DoesNotExist:
|
||||
return
|
||||
applet.slug = "my-buddies"
|
||||
applet.name = "My Buddies"
|
||||
applet.save(update_fields=["slug", "name"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0006_rename_contacts_to_buddies"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, backward),
|
||||
]
|
||||
17
src/apps/billboard/migrations/0006_alter_line_options.py
Normal file
17
src/apps/billboard/migrations/0006_alter_line_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0 on 2026-05-09 03:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0005_line_admin_solicited'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='line',
|
||||
options={'ordering': ('created_at', 'id')},
|
||||
),
|
||||
]
|
||||
@@ -1,143 +0,0 @@
|
||||
"""ITs for the My Buddies feature (User.buddies M2M + my_buddies view +
|
||||
add_buddy JSON endpoint).
|
||||
|
||||
User.buddies is a self M2M (symmetrical=False) — adding Alice to Disco's
|
||||
list does NOT auto-reciprocate. Implicit auto-add on shared events
|
||||
(post-share, gate-invite) is layered separately in those views.
|
||||
|
||||
Privacy: add_buddy returns 200 with {buddy: null} when the email is
|
||||
unregistered, so the response shape never leaks membership.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class UserBuddiesM2MTest(TestCase):
|
||||
"""The buddies field is asymmetric — A.buddies.add(B) doesn't
|
||||
reciprocate to B.buddies, only to B.added_as_buddy."""
|
||||
|
||||
def setUp(self):
|
||||
self.disco = User.objects.create(email="disco@test.io")
|
||||
self.alice = User.objects.create(email="alice@test.io")
|
||||
|
||||
def test_add_buddy_one_way(self):
|
||||
self.disco.buddies.add(self.alice)
|
||||
self.assertIn(self.alice, self.disco.buddies.all())
|
||||
self.assertNotIn(self.disco, self.alice.buddies.all())
|
||||
|
||||
def test_added_as_buddy_reverse_relation(self):
|
||||
self.disco.buddies.add(self.alice)
|
||||
self.assertIn(self.disco, self.alice.added_as_buddy.all())
|
||||
|
||||
def test_add_is_idempotent(self):
|
||||
self.disco.buddies.add(self.alice)
|
||||
self.disco.buddies.add(self.alice)
|
||||
self.assertEqual(self.disco.buddies.count(), 1)
|
||||
|
||||
|
||||
class MyBuddiesViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||
self.user.buddies.add(self.alice, self.bob)
|
||||
|
||||
def test_my_buddies_renders_template(self):
|
||||
response = self.client.get(reverse("billboard:my_buddies"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/billboard/my_buddies.html")
|
||||
|
||||
def test_my_buddies_lists_users_buddies(self):
|
||||
response = self.client.get(reverse("billboard:my_buddies"))
|
||||
buddies = list(response.context["buddies"])
|
||||
self.assertIn(self.alice, buddies)
|
||||
self.assertIn(self.bob, buddies)
|
||||
|
||||
def test_my_buddies_does_not_list_others_buddies(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
carol = User.objects.create(email="carol@test.io", username="carol")
|
||||
other.buddies.add(carol)
|
||||
response = self.client.get(reverse("billboard:my_buddies"))
|
||||
self.assertNotIn(carol, list(response.context["buddies"]))
|
||||
|
||||
def test_my_buddies_redirects_anon_to_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("billboard:my_buddies"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class AddBuddyViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@test.io", username="me")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_add_registered_email_adds_to_buddies(self):
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_buddy"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(alice, self.user.buddies.all())
|
||||
|
||||
def test_add_returns_buddy_payload_with_username(self):
|
||||
User.objects.create(email="alice@test.io", username="alice")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_buddy"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
body = response.json()
|
||||
self.assertIsNotNone(body["buddy"])
|
||||
self.assertEqual(body["buddy"]["username"], "alice")
|
||||
|
||||
def test_add_unregistered_email_returns_null_buddy(self):
|
||||
"""Privacy: 200 with buddy=null so the response shape doesn't leak
|
||||
whether the address is on the system."""
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_buddy"),
|
||||
data={"recipient": "ghost@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["buddy"])
|
||||
self.assertEqual(self.user.buddies.count(), 0)
|
||||
|
||||
def test_add_own_email_is_silent_noop(self):
|
||||
"""Adding yourself: no buddy added, response carries buddy=null."""
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_buddy"),
|
||||
data={"recipient": "me@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["buddy"])
|
||||
self.assertNotIn(self.user, self.user.buddies.all())
|
||||
|
||||
def test_add_existing_buddy_is_idempotent(self):
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.user.buddies.add(alice)
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_buddy"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Still only one buddy entry — M2M dedup
|
||||
self.assertEqual(self.user.buddies.count(), 1)
|
||||
# Response still carries the buddy payload (so the JS can refresh
|
||||
# an entry if a fast double-click bypassed the data-buddy-id guard).
|
||||
self.assertIsNotNone(response.json()["buddy"])
|
||||
|
||||
def test_add_falls_back_to_email_when_no_username(self):
|
||||
"""Buddy payload returns email when buddy.username is None — display
|
||||
layer matches the navbar fallback (display_name filter)."""
|
||||
User.objects.create(email="anon@test.io")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_buddy"),
|
||||
data={"recipient": "anon@test.io"},
|
||||
)
|
||||
self.assertEqual(response.json()["buddy"]["username"], "anon@test.io")
|
||||
|
||||
def test_get_returns_405(self):
|
||||
response = self.client.get(reverse("billboard:add_buddy"))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
143
src/apps/billboard/tests/integrated/test_buds.py
Normal file
143
src/apps/billboard/tests/integrated/test_buds.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""ITs for the My Buds feature (User.buds M2M + my_buds view +
|
||||
add_bud JSON endpoint).
|
||||
|
||||
User.buds is a self M2M (symmetrical=False) — adding Alice to Disco's
|
||||
list does NOT auto-reciprocate. Implicit auto-add on shared events
|
||||
(post-share, gate-invite) is layered separately in those views.
|
||||
|
||||
Privacy: add_bud returns 200 with {bud: null} when the email is
|
||||
unregistered, so the response shape never leaks membership.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class UserBudsM2MTest(TestCase):
|
||||
"""The buds field is asymmetric — A.buds.add(B) doesn't
|
||||
reciprocate to B.buds, only to B.added_as_bud."""
|
||||
|
||||
def setUp(self):
|
||||
self.disco = User.objects.create(email="disco@test.io")
|
||||
self.alice = User.objects.create(email="alice@test.io")
|
||||
|
||||
def test_add_bud_one_way(self):
|
||||
self.disco.buds.add(self.alice)
|
||||
self.assertIn(self.alice, self.disco.buds.all())
|
||||
self.assertNotIn(self.disco, self.alice.buds.all())
|
||||
|
||||
def test_added_as_bud_reverse_relation(self):
|
||||
self.disco.buds.add(self.alice)
|
||||
self.assertIn(self.disco, self.alice.added_as_bud.all())
|
||||
|
||||
def test_add_is_idempotent(self):
|
||||
self.disco.buds.add(self.alice)
|
||||
self.disco.buds.add(self.alice)
|
||||
self.assertEqual(self.disco.buds.count(), 1)
|
||||
|
||||
|
||||
class MyBudsViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||
self.user.buds.add(self.alice, self.bob)
|
||||
|
||||
def test_my_buds_renders_template(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/billboard/my_buds.html")
|
||||
|
||||
def test_my_buds_lists_users_buds(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
buds = list(response.context["buds"])
|
||||
self.assertIn(self.alice, buds)
|
||||
self.assertIn(self.bob, buds)
|
||||
|
||||
def test_my_buds_does_not_list_others_buds(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
carol = User.objects.create(email="carol@test.io", username="carol")
|
||||
other.buds.add(carol)
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertNotIn(carol, list(response.context["buds"]))
|
||||
|
||||
def test_my_buds_redirects_anon_to_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class AddBudViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@test.io", username="me")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_add_registered_email_adds_to_buds(self):
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(alice, self.user.buds.all())
|
||||
|
||||
def test_add_returns_bud_payload_with_username(self):
|
||||
User.objects.create(email="alice@test.io", username="alice")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
body = response.json()
|
||||
self.assertIsNotNone(body["bud"])
|
||||
self.assertEqual(body["bud"]["username"], "alice")
|
||||
|
||||
def test_add_unregistered_email_returns_null_bud(self):
|
||||
"""Privacy: 200 with bud=null so the response shape doesn't leak
|
||||
whether the address is on the system."""
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "ghost@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["bud"])
|
||||
self.assertEqual(self.user.buds.count(), 0)
|
||||
|
||||
def test_add_own_email_is_silent_noop(self):
|
||||
"""Adding yourself: no bud added, response carries bud=null."""
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "me@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["bud"])
|
||||
self.assertNotIn(self.user, self.user.buds.all())
|
||||
|
||||
def test_add_existing_bud_is_idempotent(self):
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.user.buds.add(alice)
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Still only one bud entry — M2M dedup
|
||||
self.assertEqual(self.user.buds.count(), 1)
|
||||
# Response still carries the bud payload (so the JS can refresh
|
||||
# an entry if a fast double-click bypassed the data-bud-id guard).
|
||||
self.assertIsNotNone(response.json()["bud"])
|
||||
|
||||
def test_add_falls_back_to_email_when_no_username(self):
|
||||
"""Bud payload returns email when bud.username is None — display
|
||||
layer matches the navbar fallback (display_name filter)."""
|
||||
User.objects.create(email="anon@test.io")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "anon@test.io"},
|
||||
)
|
||||
self.assertEqual(response.json()["bud"]["username"], "anon@test.io")
|
||||
|
||||
def test_get_returns_405(self):
|
||||
response = self.client.get(reverse("billboard:add_bud"))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
@@ -13,7 +13,7 @@ from apps.lyric.models import User
|
||||
def _seed_billboard_applets():
|
||||
for slug, name, cols, rows in [
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-buddies", "My Buddies", 4, 3),
|
||||
("my-buds", "My Buds", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
@@ -37,7 +37,7 @@ class BillboardViewTest(TestCase):
|
||||
self.assertIn("applets", response.context)
|
||||
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||
self.assertIn("my-scrolls", slugs)
|
||||
self.assertIn("my-buddies", slugs)
|
||||
self.assertIn("my-buds", slugs)
|
||||
self.assertIn("most-recent-scroll", slugs)
|
||||
|
||||
def test_passes_my_rooms_context(self):
|
||||
@@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
from apps.applets.models import UserApplet
|
||||
contacts = Applet.objects.get(slug="my-buddies")
|
||||
contacts = Applet.objects.get(slug="my-buds")
|
||||
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
||||
self.assertFalse(ua.visible)
|
||||
|
||||
@@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
||||
reverse("billboard:toggle_applets"),
|
||||
{"applets": [
|
||||
"my-scrolls",
|
||||
"my-buddies",
|
||||
"my-buds",
|
||||
"most-recent-scroll",
|
||||
]},
|
||||
HTTP_HX_REQUEST="true",
|
||||
@@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
||||
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
|
||||
|
||||
def test_second_toggle_preserves_prior_hidden_state(self):
|
||||
# First toggle: hide My Buddies only.
|
||||
# First toggle: hide My Buds only.
|
||||
self.client.post(
|
||||
reverse("billboard:toggle_applets"),
|
||||
{"applets": [
|
||||
@@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
||||
]},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
# Second toggle: hide Most Recent Scroll additionally — My Buddies must stay hidden.
|
||||
# Second toggle: hide Most Recent Scroll additionally — My Buds must stay hidden.
|
||||
self.client.post(
|
||||
reverse("billboard:toggle_applets"),
|
||||
{"applets": [
|
||||
@@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
from apps.applets.models import UserApplet
|
||||
contacts = Applet.objects.get(slug="my-buddies")
|
||||
contacts = Applet.objects.get(slug="my-buds")
|
||||
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
||||
self.assertFalse(
|
||||
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
||||
|
||||
@@ -18,6 +18,6 @@ urlpatterns = [
|
||||
path("post/<uuid:post_id>/", views.view_post, name="view_post"),
|
||||
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
|
||||
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
||||
path("my-buddies/", views.my_buddies, name="my_buddies"),
|
||||
path("buddies/add", views.add_buddy, name="add_buddy"),
|
||||
path("my-buds/", views.my_buds, name="my_buds"),
|
||||
path("buds/add", views.add_bud, name="add_bud"),
|
||||
]
|
||||
|
||||
@@ -300,7 +300,7 @@ def my_posts(request, user_id):
|
||||
return HttpResponseForbidden()
|
||||
return render(request, "apps/billboard/my_posts.html", {
|
||||
"owner": owner,
|
||||
"page_class": "page-billboard",
|
||||
"page_class": "page-billposts",
|
||||
})
|
||||
|
||||
|
||||
@@ -373,44 +373,44 @@ def share_post(request, post_id):
|
||||
return redirect(our_post)
|
||||
|
||||
|
||||
# ── My Buddies ────────────────────────────────────────────────────────────
|
||||
# User.buddies is an asymmetric self M2M (lyric/0004). `my_buddies` is the
|
||||
# manage-page; `add_buddy` is the JSON endpoint hit by the buddy panel slide-
|
||||
# out. Privacy: when an entered email isn't a registered User, we 200 with
|
||||
# {buddy: null} so the response shape doesn't leak membership.
|
||||
# ── My Buds ───────────────────────────────────────────────────────────────
|
||||
# User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds`
|
||||
# is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel
|
||||
# slide-out. Privacy: when an entered email isn't a registered User, we
|
||||
# 200 with {bud: null} so the response shape doesn't leak membership.
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_buddies(request):
|
||||
return render(request, "apps/billboard/my_buddies.html", {
|
||||
"buddies": request.user.buddies.all(),
|
||||
"page_class": "page-billbuddies",
|
||||
def my_buds(request):
|
||||
return render(request, "apps/billboard/my_buds.html", {
|
||||
"buds": request.user.buds.all(),
|
||||
"page_class": "page-billbuds",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def add_buddy(request):
|
||||
def add_bud(request):
|
||||
if request.method != "POST":
|
||||
from django.http import HttpResponseNotAllowed
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
|
||||
email = (request.POST.get("recipient") or "").strip()
|
||||
|
||||
buddy = None
|
||||
bud = None
|
||||
try:
|
||||
candidate = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
candidate = None
|
||||
|
||||
if candidate is not None and candidate != request.user:
|
||||
if candidate not in request.user.buddies.all():
|
||||
request.user.buddies.add(candidate)
|
||||
buddy = {
|
||||
if candidate not in request.user.buds.all():
|
||||
request.user.buds.add(candidate)
|
||||
bud = {
|
||||
"id": str(candidate.id),
|
||||
"username": candidate.username or candidate.email,
|
||||
"email": candidate.email,
|
||||
}
|
||||
|
||||
return JsonResponse({"buddy": buddy})
|
||||
return JsonResponse({"bud": bud})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
|
||||
@@ -83,6 +83,6 @@ const Note = Brief;
|
||||
|
||||
// `const Brief = (...)` at script-tag scope is reachable as a bare name but
|
||||
// is NOT auto-attached to window — explicit assignment so callers that gate
|
||||
// on `if (window.Brief)` (e.g. _buddy_panel.html's OK handler) succeed.
|
||||
// on `if (window.Brief)` (e.g. _bud_panel.html's OK handler) succeed.
|
||||
window.Brief = Brief;
|
||||
window.Note = Note;
|
||||
|
||||
32
src/apps/lyric/migrations/0005_rename_buddies_to_buds.py
Normal file
32
src/apps/lyric/migrations/0005_rename_buddies_to_buds.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Rename User.buddies → User.buds.
|
||||
|
||||
Django's RenameField doesn't rename the implicit M2M through table
|
||||
(`lyric_user_buddies` → `lyric_user_buds`), so we drop and re-add the
|
||||
field. The buddies M2M was introduced one commit prior (0004) — no
|
||||
production data to preserve. UI-vocabulary tightening (BILLBUDDIES
|
||||
overflowed the page-header band; in-game term collapses to BILLBUDS).
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("lyric", "0004_user_buddies"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="buddies",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="buds",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="added_as_bud",
|
||||
to="lyric.user",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -113,13 +113,13 @@ class User(AbstractBaseUser):
|
||||
unlocked_decks = models.ManyToManyField(
|
||||
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
||||
)
|
||||
# Asymmetric self M2M — `user.buddies.all()` = people I've explicitly
|
||||
# Asymmetric self M2M — `user.buds.all()` = people I've explicitly
|
||||
# added (or implicitly via post-share / game-invite, which auto-adds
|
||||
# the recipient to the inviter's buddies list). `user.added_as_buddy`
|
||||
# = the inverse (people who have me in their buddies list); useful
|
||||
# for the future "buddy changed username" snapshot-accept flow.
|
||||
buddies = models.ManyToManyField(
|
||||
"self", symmetrical=False, blank=True, related_name="added_as_buddy",
|
||||
# the recipient to the inviter's buds list). `user.added_as_bud` is
|
||||
# the inverse (people who have me in their buds list); useful for
|
||||
# the future "bud changed username" snapshot-accept flow.
|
||||
buds = models.ManyToManyField(
|
||||
"self", symmetrical=False, blank=True, related_name="added_as_bud",
|
||||
)
|
||||
active_title = models.ForeignKey(
|
||||
"drama.Note", null=True, blank=True,
|
||||
|
||||
@@ -55,17 +55,17 @@ class PostPage:
|
||||
)
|
||||
|
||||
def share_post_with(self, email):
|
||||
# Buddy-btn flow (post-Brief sprint): click bottom-left handshake,
|
||||
# Bud-btn flow (post-Brief sprint): click bottom-left handshake,
|
||||
# type the email in the slide-out, click the .btn-confirm OK, wait
|
||||
# for the recipient chip.
|
||||
buddy_btn = self.test.browser.find_element(By.ID, "id_buddy_btn")
|
||||
buddy_btn.click()
|
||||
bud_btn = self.test.browser.find_element(By.ID, "id_bud_btn")
|
||||
bud_btn.click()
|
||||
recipient = self.test.wait_for(
|
||||
lambda: self.test.browser.find_element(By.ID, "id_recipient")
|
||||
)
|
||||
recipient.send_keys(email)
|
||||
ok = self.test.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm"
|
||||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||||
)
|
||||
ok.click()
|
||||
self.test.wait_for(
|
||||
|
||||
@@ -59,21 +59,21 @@ class AdminPostInputReadonlyTest(FunctionalTest):
|
||||
)
|
||||
|
||||
|
||||
class AdminPostHasNoBuddyBtnTest(FunctionalTest):
|
||||
"""Admin-Post (note-unlock thread) suppresses #id_buddy_btn — friend
|
||||
class AdminPostHasNoBudBtnTest(FunctionalTest):
|
||||
"""Admin-Post (note-unlock thread) suppresses #id_bud_btn — friend
|
||||
invites don't apply to system-authored threads. User-Post still
|
||||
renders the btn (regression coverage in test_buddy_btn.py)."""
|
||||
renders the btn (regression coverage in test_bud_btn.py)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gamer = User.objects.create(email="nobuddy@test.io")
|
||||
self.gamer = User.objects.create(email="nobud@test.io")
|
||||
Note.grant_if_new(self.gamer, "stargazer")
|
||||
self.admin_post = Post.objects.get(
|
||||
owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK,
|
||||
)
|
||||
self.create_pre_authenticated_session("nobuddy@test.io")
|
||||
self.create_pre_authenticated_session("nobud@test.io")
|
||||
|
||||
def test_buddy_btn_absent_on_admin_post(self):
|
||||
def test_bud_btn_absent_on_admin_post(self):
|
||||
self.browser.get(
|
||||
self.live_server_url + f"/billboard/post/{self.admin_post.id}/"
|
||||
)
|
||||
@@ -82,8 +82,8 @@ class AdminPostHasNoBuddyBtnTest(FunctionalTest):
|
||||
lambda: self.browser.find_element(By.ID, "id_post_line_text")
|
||||
)
|
||||
self.assertFalse(
|
||||
self.browser.find_elements(By.ID, "id_buddy_btn"),
|
||||
"Admin-Post must NOT render #id_buddy_btn",
|
||||
self.browser.find_elements(By.ID, "id_bud_btn"),
|
||||
"Admin-Post must NOT render #id_bud_btn",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest):
|
||||
super().setUp()
|
||||
for slug, name, cols, rows in [
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-buddies", "My Buddies", 4, 3),
|
||||
("my-buds", "My Buds", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
@@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest):
|
||||
class BillboardAppletsTest(FunctionalTest):
|
||||
"""
|
||||
FT: billboard page renders three applets in the grid — My Scrolls,
|
||||
My Buddies, and Most Recent Scroll — with a functioning gear menu.
|
||||
My Buds, and Most Recent Scroll — with a functioning gear menu.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
||||
for slug, name, cols, rows in [
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-buddies", "My Buddies", 4, 3),
|
||||
("my-buds", "My Buds", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
@@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||
)
|
||||
self.browser.find_element(By.ID, "id_applet_my_buddies")
|
||||
self.browser.find_element(By.ID, "id_applet_my_buds")
|
||||
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
||||
|
||||
def test_billboard_my_scrolls_lists_rooms(self):
|
||||
@@ -278,7 +278,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
||||
)
|
||||
contacts_cb = menu.find_element(
|
||||
By.CSS_SELECTOR, "input[value='my-buddies']"
|
||||
By.CSS_SELECTOR, "input[value='my-buds']"
|
||||
)
|
||||
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
||||
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||
@@ -286,7 +286,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buds"),
|
||||
[],
|
||||
)
|
||||
)
|
||||
@@ -305,7 +305,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
)
|
||||
# The freshly-rendered menu must reflect DB state (Contacts unchecked)
|
||||
contacts_cb = menu.find_element(
|
||||
By.CSS_SELECTOR, "input[value='my-buddies']"
|
||||
By.CSS_SELECTOR, "input[value='my-buds']"
|
||||
)
|
||||
self.assertFalse(contacts_cb.is_selected())
|
||||
most_recent_scroll_cb = menu.find_element(
|
||||
@@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buds"),
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -332,7 +332,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||
)
|
||||
self.assertEqual(
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buds"),
|
||||
[],
|
||||
)
|
||||
self.assertEqual(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""FT spec for the Buddy btn sprint — post.html bottom-left handshake button.
|
||||
"""FT spec for the Bud btn sprint — post.html bottom-left handshake button.
|
||||
|
||||
Written red BEFORE implementation as a TDD handoff so the post-compaction
|
||||
agent (or future Disco) can land the feature without losing intent. Run:
|
||||
|
||||
python src/manage.py test functional_tests.test_buddy_btn
|
||||
python src/manage.py test functional_tests.test_bud_btn
|
||||
|
||||
All tests should be RED initially. Implementation lands when they go green.
|
||||
|
||||
@@ -11,22 +11,22 @@ All tests should be RED initially. Implementation lands when they go green.
|
||||
SPEC SUMMARY
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
• A new #id_buddy_btn (<i class="fa-solid fa-handshake">) sits bottom-left
|
||||
• A new #id_bud_btn (<i class="fa-solid fa-handshake">) sits bottom-left
|
||||
of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same
|
||||
fixed/circular/secUser-bordered look + .active-state styling.
|
||||
• Lives in a partial template, e.g. apps/billboard/_partials/_buddy_panel.html,
|
||||
included only by post.html (NOT the global base.html — buddy is post-only).
|
||||
• Lives in a partial template, e.g. apps/billboard/_partials/_bud_panel.html,
|
||||
included only by post.html (NOT the global base.html — bud is post-only).
|
||||
• Replaces the inline share form on post.html: typing the recipient now
|
||||
happens in a slide-out under the buddy btn.
|
||||
• Click #id_buddy_btn → recipient field grows L→R under it, spanning
|
||||
happens in a slide-out under the bud btn.
|
||||
• Click #id_bud_btn → recipient field grows L→R under it, spanning
|
||||
`100vw - 3rem` (1.5rem padding each side). The field is vertically
|
||||
centred on the buddy btn's centre, w. healthy left padding so the typed
|
||||
centred on the bud btn's centre, w. healthy left padding so the typed
|
||||
text + placeholder don't overlap the btn glyph.
|
||||
• An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the
|
||||
field (replaces the legacy big SHARE .btn-primary).
|
||||
• While the recipient field is open: html.buddy-open is set; #id_kit_btn
|
||||
• While the recipient field is open: html.bud-open is set; #id_kit_btn
|
||||
quickly eases to opacity 0. Symmetric: when html.kit-open is set,
|
||||
#id_buddy_btn eases to opacity 0.
|
||||
#id_bud_btn eases to opacity 0.
|
||||
• Click OK → POST share-post async (existing C3.b endpoint), clears the
|
||||
field, closes the slide-out, slide-down Brief banner appears.
|
||||
• Click outside the field/btn → closes the slide-out, clears the field
|
||||
@@ -40,29 +40,29 @@ SPEC SUMMARY
|
||||
IMPLEMENTATION CHECKLIST (post-compaction)
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
1. Create templates/apps/billboard/_partials/_buddy_panel.html w. the btn
|
||||
1. Create templates/apps/billboard/_partials/_bud_panel.html w. the btn
|
||||
+ slide-out form + inline JS.
|
||||
2. Edit templates/apps/billboard/post.html:
|
||||
- Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn
|
||||
block (the share JS moves into _buddy_panel.html).
|
||||
- Add `{% include "apps/billboard/_partials/_buddy_panel.html" %}`.
|
||||
block (the share JS moves into _bud_panel.html).
|
||||
- Add `{% include "apps/billboard/_partials/_bud_panel.html" %}`.
|
||||
3. Edit billboard.views.view_post (or my_posts) context:
|
||||
- "page_class": "page-billboard" (or new page-post) so the body class
|
||||
picks up the aperture SCSS.
|
||||
4. SCSS — add to _game-kit.scss neighbour or new _buddy.scss:
|
||||
- #id_buddy_btn: position fixed bottom-left, mirror of #id_kit_btn
|
||||
4. SCSS — add to _game-kit.scss neighbour or new _bud.scss:
|
||||
- #id_bud_btn: position fixed bottom-left, mirror of #id_kit_btn
|
||||
(3rem circle, secUser border, .active state, etc.)
|
||||
- #id_buddy_panel (the slide-out wrapper): position fixed,
|
||||
- #id_bud_panel (the slide-out wrapper): position fixed,
|
||||
left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when
|
||||
kit btn visible — but mutual-exclusion makes that moot), bottom-
|
||||
aligned w. the btn centre, transition transform/width L→R.
|
||||
- html.buddy-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
|
||||
- html.kit-open #id_buddy_btn { opacity: 0; transition: opacity 0.15s; }
|
||||
- html.bud-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
|
||||
- html.kit-open #id_bud_btn { opacity: 0; transition: opacity 0.15s; }
|
||||
- The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on
|
||||
the trailing edge of the slide-out.
|
||||
5. JS in _buddy_panel.html:
|
||||
5. JS in _bud_panel.html:
|
||||
- Mirror game-kit.js click/escape/click-outside pattern.
|
||||
- Toggle html.buddy-open + #id_buddy_btn.active.
|
||||
- Toggle html.bud-open + #id_bud_btn.active.
|
||||
- On submit/OK: fetch POST share-post w. Accept:application/json,
|
||||
reuse the C3.b response handling (line append, banner via
|
||||
Brief.showBanner, recipient_display chip append).
|
||||
@@ -70,7 +70,7 @@ IMPLEMENTATION CHECKLIST (post-compaction)
|
||||
6. post.html + my_posts.html: add the body class hook so the aperture
|
||||
SCSS engages (probably page-billboard already, just need to confirm).
|
||||
7. Update functional_tests.post_page.PostPage.share_post_with() to
|
||||
drive the buddy-btn flow (click btn → type → click OK → wait for chip).
|
||||
drive the bud-btn flow (click btn → type → click OK → wait for chip).
|
||||
8. Re-run test_sharing.SharingTest — should still pass once the page-
|
||||
object mirrors the new flow.
|
||||
|
||||
@@ -82,7 +82,7 @@ KNOWN AT TIME OF WRITING
|
||||
- The C3.b share-post async endpoint accepts Accept: application/json,
|
||||
returns {brief, line_text, recipient_display}; intercepted by the
|
||||
inline JS in post.html's existing #id_share_form. That JS moves into
|
||||
_buddy_panel.html.
|
||||
_bud_panel.html.
|
||||
- body.page-billboard class is set by billboard:billboard view; post.html
|
||||
needs it (or its own class) added in billboard.views.view_post.
|
||||
"""
|
||||
@@ -104,8 +104,8 @@ def _seed_a_post(user):
|
||||
return p
|
||||
|
||||
|
||||
class BuddyBtnPresenceTest(FunctionalTest):
|
||||
"""The buddy btn is post-only — present on post.html, absent elsewhere."""
|
||||
class BudBtnPresenceTest(FunctionalTest):
|
||||
"""The bud btn is post-only — present on post.html, absent elsewhere."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -113,46 +113,46 @@ class BuddyBtnPresenceTest(FunctionalTest):
|
||||
slug="my-posts",
|
||||
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
|
||||
)
|
||||
self.gamer = User.objects.create(email="buddy@test.io")
|
||||
self.gamer = User.objects.create(email="bud@test.io")
|
||||
self.post = _seed_a_post(self.gamer)
|
||||
self.create_pre_authenticated_session("buddy@test.io")
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
|
||||
# ── B1 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_buddy_btn_renders_on_post_html(self):
|
||||
def test_bud_btn_renders_on_post_html(self):
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake")
|
||||
self.assertIsNotNone(icon)
|
||||
|
||||
# ── B2 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_buddy_btn_absent_on_dashboard(self):
|
||||
def test_bud_btn_absent_on_dashboard(self):
|
||||
self.browser.get(self.live_server_url + "/")
|
||||
# Allow page to settle
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
||||
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn"))
|
||||
self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
|
||||
|
||||
# ── B3 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_buddy_btn_absent_on_billboard_index(self):
|
||||
def test_bud_btn_absent_on_billboard_index(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
||||
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn"))
|
||||
self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
|
||||
|
||||
|
||||
class BuddyBtnPositionTest(FunctionalTest):
|
||||
class BudBtnPositionTest(FunctionalTest):
|
||||
"""The btn sits bottom-left, mirror of #id_kit_btn's bottom-right."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gamer = User.objects.create(email="buddy@test.io")
|
||||
self.gamer = User.objects.create(email="bud@test.io")
|
||||
self.post = _seed_a_post(self.gamer)
|
||||
self.create_pre_authenticated_session("buddy@test.io")
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
|
||||
def test_buddy_btn_is_fixed_bottom_left(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
def test_bud_btn_is_fixed_bottom_left(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
cs = self.browser.execute_script(
|
||||
"var s = getComputedStyle(arguments[0]); "
|
||||
"return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};",
|
||||
@@ -164,9 +164,9 @@ class BuddyBtnPositionTest(FunctionalTest):
|
||||
self.assertNotEqual(cs["bottom"], "auto")
|
||||
self.assertNotEqual(cs["left"], "auto")
|
||||
|
||||
def test_buddy_btn_size_matches_kit_btn(self):
|
||||
def test_bud_btn_size_matches_kit_btn(self):
|
||||
"""Same circular-3rem look — visually a mirror pair."""
|
||||
btn = self.browser.find_element(By.ID, "id_buddy_btn")
|
||||
btn = self.browser.find_element(By.ID, "id_bud_btn")
|
||||
kit = self.browser.find_element(By.ID, "id_kit_btn")
|
||||
b_box = btn.size
|
||||
k_box = kit.size
|
||||
@@ -174,47 +174,47 @@ class BuddyBtnPositionTest(FunctionalTest):
|
||||
self.assertEqual(b_box["height"], k_box["height"])
|
||||
|
||||
|
||||
class BuddyBtnSlideOutTest(FunctionalTest):
|
||||
"""Click the buddy btn → recipient field + OK btn slide out under it."""
|
||||
class BudBtnSlideOutTest(FunctionalTest):
|
||||
"""Click the bud btn → recipient field + OK btn slide out under it."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gamer = User.objects.create(email="buddy@test.io")
|
||||
self.gamer = User.objects.create(email="bud@test.io")
|
||||
self.post = _seed_a_post(self.gamer)
|
||||
self.create_pre_authenticated_session("buddy@test.io")
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
|
||||
def test_recipient_field_hidden_until_click(self):
|
||||
"""Pre-click: the field is in DOM but visually closed (e.g. width 0
|
||||
or transform scaleX(0)) — assertion checks it doesn't take its full
|
||||
viewport-spanning width yet."""
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
# The field+OK panel is rendered (so JS can transition it) but should
|
||||
# be in a closed state — assert the panel container exists and the
|
||||
# input is not displayed at full width (a CSS-driven slide-out).
|
||||
panel = self.browser.find_element(By.ID, "id_buddy_panel")
|
||||
panel = self.browser.find_element(By.ID, "id_bud_panel")
|
||||
# Before click, panel visible-width should be < viewport / 2 (closed)
|
||||
viewport_w = self.browser.execute_script("return window.innerWidth;")
|
||||
self.assertLess(panel.size["width"], viewport_w / 2)
|
||||
|
||||
def test_click_buddy_btn_reveals_recipient_field_and_ok_btn(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
def test_click_bud_btn_reveals_recipient_field_and_ok_btn(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
self.assertTrue(recipient.is_displayed())
|
||||
|
||||
# OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
|
||||
self.assertEqual(ok.text.strip().upper(), "OK")
|
||||
# Buddy btn picks up .active when open (mirror kit-btn pattern)
|
||||
# Bud btn picks up .active when open (mirror kit-btn pattern)
|
||||
self.assertIn("active", btn.get_attribute("class"))
|
||||
|
||||
def test_panel_spans_almost_full_viewport_when_open(self):
|
||||
"""When open, the panel spans 100vw - 3rem (1.5rem each side)."""
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
panel = self.browser.find_element(By.ID, "id_buddy_panel")
|
||||
panel = self.browser.find_element(By.ID, "id_bud_panel")
|
||||
# Wait for the slide-out transition to settle
|
||||
self.wait_for(lambda: self.assertGreater(
|
||||
panel.size["width"],
|
||||
@@ -222,32 +222,32 @@ class BuddyBtnSlideOutTest(FunctionalTest):
|
||||
))
|
||||
|
||||
def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
pad = self.browser.execute_script(
|
||||
"return parseFloat(getComputedStyle(arguments[0]).paddingLeft);",
|
||||
recipient,
|
||||
)
|
||||
# At least 2.5rem (40px-ish) so the buddy glyph (3rem circle) doesn't
|
||||
# At least 2.5rem (40px-ish) so the bud glyph (3rem circle) doesn't
|
||||
# overlap the placeholder/typed text.
|
||||
self.assertGreaterEqual(pad, 32)
|
||||
|
||||
|
||||
class BuddyKitMutualExclusionTest(FunctionalTest):
|
||||
"""When kit btn is active, buddy btn fades to 0 — and vice-versa."""
|
||||
class BudKitMutualExclusionTest(FunctionalTest):
|
||||
"""When kit btn is active, bud btn fades to 0 — and vice-versa."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gamer = User.objects.create(email="buddy@test.io")
|
||||
self.gamer = User.objects.create(email="bud@test.io")
|
||||
self.post = _seed_a_post(self.gamer)
|
||||
self.create_pre_authenticated_session("buddy@test.io")
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
|
||||
def test_buddy_active_fades_kit_btn(self):
|
||||
buddy = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
def test_bud_active_fades_kit_btn(self):
|
||||
bud = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
kit = self.browser.find_element(By.ID, "id_kit_btn")
|
||||
buddy.click()
|
||||
bud.click()
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.execute_script(
|
||||
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
||||
@@ -256,32 +256,32 @@ class BuddyKitMutualExclusionTest(FunctionalTest):
|
||||
0.0,
|
||||
))
|
||||
|
||||
def test_kit_active_fades_buddy_btn(self):
|
||||
def test_kit_active_fades_bud_btn(self):
|
||||
kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
||||
buddy = self.browser.find_element(By.ID, "id_buddy_btn")
|
||||
bud = self.browser.find_element(By.ID, "id_bud_btn")
|
||||
kit.click()
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.execute_script(
|
||||
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
||||
buddy,
|
||||
bud,
|
||||
),
|
||||
0.0,
|
||||
))
|
||||
|
||||
|
||||
class BuddyBtnDismissTest(FunctionalTest):
|
||||
class BudBtnDismissTest(FunctionalTest):
|
||||
"""Click outside / Escape closes the panel; field is cleared; reopening
|
||||
shows the placeholder, not the previously-typed value."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gamer = User.objects.create(email="buddy@test.io")
|
||||
self.gamer = User.objects.create(email="bud@test.io")
|
||||
self.post = _seed_a_post(self.gamer)
|
||||
self.create_pre_authenticated_session("buddy@test.io")
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
|
||||
def _open_and_type(self, text):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys(text)
|
||||
@@ -310,24 +310,24 @@ class BuddyBtnDismissTest(FunctionalTest):
|
||||
self.assertEqual(recipient.get_attribute("value"), "")
|
||||
|
||||
|
||||
class BuddyBtnOkSubmitsAsyncShareTest(FunctionalTest):
|
||||
class BudBtnOkSubmitsAsyncShareTest(FunctionalTest):
|
||||
"""OK → POST share-post (Accept:application/json) → Brief banner +
|
||||
recipient chip appended; field clears; panel closes."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.sharer = User.objects.create(email="buddy@test.io")
|
||||
self.sharer = User.objects.create(email="bud@test.io")
|
||||
self.recipient = User.objects.create(email="alice@test.io")
|
||||
self.post = _seed_a_post(self.sharer)
|
||||
self.create_pre_authenticated_session("buddy@test.io")
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
|
||||
def test_ok_creates_brief_appends_line_and_chip(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient_input.send_keys("alice@test.io")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
|
||||
ok.click()
|
||||
|
||||
# 1. Brief is created server-side
|
||||
@@ -358,9 +358,9 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
|
||||
slug="my-posts",
|
||||
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
|
||||
)
|
||||
self.gamer = User.objects.create(email="buddy@test.io")
|
||||
self.gamer = User.objects.create(email="bud@test.io")
|
||||
self.post = _seed_a_post(self.gamer)
|
||||
self.create_pre_authenticated_session("buddy@test.io")
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
|
||||
def test_post_html_body_carries_billboard_or_post_page_class(self):
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
@@ -134,7 +134,7 @@ class PronounsAppletFlowTest(FunctionalTest):
|
||||
# Billboard applets — page renders blank without these
|
||||
for slug, name, cols, rows in [
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-buddies", "My Buddies", 4, 3),
|
||||
("my-buds", "My Buds", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""FT for the My Buddies page — buddy btn + slide-out add flow.
|
||||
"""FT for the My Buds page — bud btn + slide-out add flow.
|
||||
|
||||
Phase 1 of the buddies sprint: explicit add via my_buddies.html. Phase 2
|
||||
Phase 1 of the buds sprint: explicit add via my_buds.html. Phase 2
|
||||
will layer autocomplete (sky-place-style top-3 username suggestions) and
|
||||
implicit auto-add on post-share / gate-invite.
|
||||
"""
|
||||
@@ -12,64 +12,64 @@ from apps.lyric.models import User
|
||||
from .base import FunctionalTest
|
||||
|
||||
|
||||
class MyBuddiesPageTest(FunctionalTest):
|
||||
class MyBudsPageTest(FunctionalTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.create_pre_authenticated_session("me@test.io")
|
||||
|
||||
def test_renders_existing_buddies(self):
|
||||
"""Pre-existing buddies show up as entries on first render."""
|
||||
self.gamer.buddies.add(self.alice)
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
def test_renders_existing_buds(self):
|
||||
"""Pre-existing buds show up as entries on first render."""
|
||||
self.gamer.buds.add(self.alice)
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||
entry = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry .buddy-name")
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".bud-entry .bud-name")
|
||||
)
|
||||
self.assertEqual(entry.text, "alice")
|
||||
|
||||
def test_empty_state_when_no_buddies(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
def test_empty_state_when_no_buds(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||
empty = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry--empty")
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".applet-list-entry--empty")
|
||||
)
|
||||
self.assertIn("No buddies yet", empty.text)
|
||||
self.assertIn("No buds yet", empty.text)
|
||||
|
||||
def test_add_buddy_via_buddy_btn_appends_entry(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
def test_add_bud_via_bud_btn_appends_entry(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys("alice@test.io")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
|
||||
ok.click()
|
||||
|
||||
# New entry appears w. alice's username (not the bare email)
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".buddy-entry[data-buddy-id='{self.alice.id}'] .buddy-name"
|
||||
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name"
|
||||
).text,
|
||||
"alice",
|
||||
))
|
||||
# Server-side persisted
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
self.alice, list(self.gamer.buddies.all())
|
||||
self.alice, list(self.gamer.buds.all())
|
||||
))
|
||||
|
||||
def test_add_unregistered_email_is_silent_noop(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys("ghost@test.io")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
|
||||
ok.click()
|
||||
|
||||
# Wait for the panel close (a positive signal the request landed)
|
||||
self.wait_for(lambda: self.assertNotIn(
|
||||
"active", btn.get_attribute("class")
|
||||
))
|
||||
# No entries beyond the empty-state row
|
||||
entries = self.browser.find_elements(By.CSS_SELECTOR, ".buddy-entry:not(.buddy-entry--empty)")
|
||||
# No bud entries (the empty-state row has its own --empty class)
|
||||
entries = self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")
|
||||
self.assertEqual(len(entries), 0)
|
||||
@@ -36,14 +36,16 @@
|
||||
html:has(body.page-billboard),
|
||||
html:has(body.page-billscroll),
|
||||
html:has(body.page-billpost),
|
||||
html:has(body.page-billbuddies) {
|
||||
html:has(body.page-billbuds),
|
||||
html:has(body.page-billposts) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.page-billboard,
|
||||
body.page-billscroll,
|
||||
body.page-billpost,
|
||||
body.page-billbuddies {
|
||||
body.page-billbuds,
|
||||
body.page-billposts {
|
||||
overflow: hidden;
|
||||
|
||||
.container {
|
||||
@@ -217,54 +219,62 @@ body.page-billbuddies {
|
||||
}
|
||||
}
|
||||
|
||||
// ── My Buddies page (aperture list + add-buddy panel) ────────────────────
|
||||
// Mirrors .post-page's flex-column / overflow / bottom-anchor pattern;
|
||||
// the add-buddy panel is included from the same _buddy*.scss styling.
|
||||
// ── Applet-list page (Billbuds, Billposts) ───────────────────────────────
|
||||
// Shared shell for pages built around _applet-list-shell.html — vertical
|
||||
// title rotated on the left of an .applet-scroll card + scrollable <ul>
|
||||
// aperture. `--single` hosts one section (My Buds); `--two-up` stacks
|
||||
// two sections in portrait, places them side-by-side in landscape (My
|
||||
// Posts: own + shared).
|
||||
|
||||
.buddies-page {
|
||||
.applet-list-page {
|
||||
@extend %billboard-page-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
.buddies-header {
|
||||
flex-shrink: 0;
|
||||
.applet-scroll {
|
||||
@extend %applet-box;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.buddies-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
#id_buddies_list {
|
||||
.applet-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.75rem 0 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.buddy-entry {
|
||||
.applet-list-entry {
|
||||
padding: 0.4rem 0;
|
||||
|
||||
.buddy-name {
|
||||
.bud-name { font-weight: bold; opacity: 0.85; }
|
||||
|
||||
&--empty { opacity: 0.6; font-style: italic; }
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
&:hover { opacity: 0.85; }
|
||||
}
|
||||
}
|
||||
|
||||
.buddy-entry-buffer {
|
||||
.applet-list-buffer {
|
||||
flex-shrink: 0;
|
||||
height: 0.25rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Side-by-side in landscape; stacked in portrait (default).
|
||||
&--two-up {
|
||||
@media (orientation: landscape) {
|
||||
flex-direction: row;
|
||||
.applet-scroll { flex: 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,13 +286,13 @@ body.page-billbuddies {
|
||||
|
||||
#id_billboard_applets_container {
|
||||
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
|
||||
#id_applet_my_buddies { grid-column: 1 / span 4; grid-row: 4 / span 3; }
|
||||
#id_applet_my_buds { grid-column: 1 / span 4; grid-row: 4 / span 3; }
|
||||
#id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; }
|
||||
#id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; }
|
||||
|
||||
@container (max-width: 550px) {
|
||||
#id_applet_my_scrolls,
|
||||
#id_applet_my_buddies,
|
||||
#id_applet_my_buds,
|
||||
#id_applet_notes,
|
||||
#id_applet_most_recent_scroll {
|
||||
grid-column: 1 / span 12;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// ── Buddy btn (bottom-left mirror of #id_kit_btn) ─────────────────────────
|
||||
// ── Bud btn (bottom-left mirror of #id_kit_btn) ─────────────────────────
|
||||
//
|
||||
// Lives on post.html only — slide-out recipient field for the share-post
|
||||
// async flow. Mutually exclusive w. #id_kit_btn (bottom-right): when one is
|
||||
// active (.active class on btn + html.{kit|buddy}-open class on root), the
|
||||
// active (.active class on btn + html.{kit|bud}-open class on root), the
|
||||
// other quickly fades to opacity 0.
|
||||
//
|
||||
// Spec: functional_tests/test_buddy_btn.py.
|
||||
// Spec: functional_tests/test_bud_btn.py.
|
||||
|
||||
#id_buddy_btn {
|
||||
#id_bud_btn {
|
||||
position: fixed;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
@@ -43,12 +43,12 @@
|
||||
}
|
||||
|
||||
// Slide-out panel: collapsed by default; opens to span ~viewport - 3rem.
|
||||
#id_buddy_panel {
|
||||
#id_bud_panel {
|
||||
position: fixed;
|
||||
bottom: 0.5rem; // align bottom edge w. buddy btn
|
||||
bottom: 0.5rem; // align bottom edge w. bud btn
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
height: 3rem; // match buddy btn height for vertical-centre alignment
|
||||
height: 3rem; // match bud btn height for vertical-centre alignment
|
||||
z-index: 317;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -56,7 +56,7 @@
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
// Closed state — collapse leftward into the buddy btn
|
||||
// Closed state — collapse leftward into the bud btn
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.2s ease-out, opacity 0.15s ease;
|
||||
@@ -76,7 +76,7 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
// Generous left padding so the buddy btn glyph (3rem circle pinned
|
||||
// Generous left padding so the bud btn glyph (3rem circle pinned
|
||||
// at left:1.5rem) doesn't visually overlap the placeholder/typed text.
|
||||
padding: 0 1rem 0 3.5rem;
|
||||
background-color: rgba(var(--priUser), 1);
|
||||
@@ -97,9 +97,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// html.buddy-open: slide the panel out, fade the kit btn away.
|
||||
html.buddy-open {
|
||||
#id_buddy_panel {
|
||||
// html.bud-open: slide the panel out, fade the kit btn away.
|
||||
html.bud-open {
|
||||
#id_bud_panel {
|
||||
transform: scaleX(1);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
@@ -111,10 +111,10 @@ html.buddy-open {
|
||||
}
|
||||
}
|
||||
|
||||
// Kit dialog open: hide the buddy btn. We don't add an `html.kit-open`
|
||||
// Kit dialog open: hide the bud btn. We don't add an `html.kit-open`
|
||||
// class (game-kit.js uses [open] on the dialog + .active on the btn), so
|
||||
// the mutual-exclusion is driven by `:has()` against the open dialog.
|
||||
html:has(#id_kit_bag_dialog[open]) #id_buddy_btn {
|
||||
html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
@import 'note';
|
||||
@import 'tooltips';
|
||||
@import 'game-kit';
|
||||
@import 'buddy';
|
||||
@import 'bud';
|
||||
@import 'wallet-tokens';
|
||||
|
||||
|
||||
|
||||
23
src/templates/apps/applets/_partials/_applet-list-shell.html
Normal file
23
src/templates/apps/applets/_partials/_applet-list-shell.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{# Shared applet-scroll-style list section — vertical-title <h2> on the #}
|
||||
{# left + scrollable <ul> aperture. Inclusion shell (NOT a base template) #}
|
||||
{# so a single page can invoke it more than once (e.g. my_posts.html #}
|
||||
{# stacks "My Posts" + "Posts shared with me"). #}
|
||||
{# #}
|
||||
{# Parameters: #}
|
||||
{# shell_title — vertical-rotated heading text (string) #}
|
||||
{# shell_items — iterable rendered into the list #}
|
||||
{# shell_item_template — partial rendering each <li>; receives `item` #}
|
||||
{# shell_list_id — optional `id=` for the <ul> (e.g. "id_buds_list" #}
|
||||
{# so buddy-panel JS can target it) #}
|
||||
{# shell_empty — text for the {% empty %} fallback row #}
|
||||
<section class="applet-scroll">
|
||||
<h2>{{ shell_title }}</h2>
|
||||
<ul {% if shell_list_id %}id="{{ shell_list_id }}"{% endif %} class="applet-list">
|
||||
{% for item in shell_items %}
|
||||
{% include shell_item_template %}
|
||||
{% empty %}
|
||||
<li class="applet-list-entry applet-list-entry--empty">{{ shell_empty|default:"Nothing here yet." }}</li>
|
||||
{% endfor %}
|
||||
<li class="applet-list-buffer" aria-hidden="true"></li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -1,7 +1,7 @@
|
||||
<section
|
||||
id="id_applet_my_buddies"
|
||||
id="id_applet_my_buds"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2><a href="{% url 'billboard:my_buddies' %}" class="my-buddies-main">My Buddies</a></h2>
|
||||
<h2><a href="{% url 'billboard:my_buds' %}" class="my-buds-main">My Buds</a></h2>
|
||||
{% include "core/_partials/_forthcoming.html" %}
|
||||
</section>
|
||||
@@ -1,32 +1,32 @@
|
||||
{% load static %}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
{# _buddy_add_panel.html — bottom-left handshake btn + slide-out add- #}
|
||||
{# buddy field. Mirrors _buddy_panel.html (post-share) but POSTs to #}
|
||||
{# add_buddy and appends to #id_buddies_list instead of #id_post_table. #}
|
||||
{# Included by my_buddies.html only. #}
|
||||
{# _bud_add_panel.html — bottom-left handshake btn + slide-out add- #}
|
||||
{# bud field. Mirrors _bud_panel.html (post-share) but POSTs to #}
|
||||
{# add_bud and appends to #id_buds_list instead of #id_post_table. #}
|
||||
{# Included by my_buds.html only. #}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
|
||||
<button id="id_buddy_btn" type="button" aria-label="Add a buddy">
|
||||
<button id="id_bud_btn" type="button" aria-label="Add a bud">
|
||||
<i class="fa-solid fa-handshake"></i>
|
||||
</button>
|
||||
|
||||
<div id="id_buddy_panel" data-add-url="{% url 'billboard:add_buddy' %}">
|
||||
<div id="id_bud_panel" data-add-url="{% url 'billboard:add_bud' %}">
|
||||
<input id="id_recipient"
|
||||
name="recipient"
|
||||
type="email"
|
||||
placeholder="friend@example.com"
|
||||
autocomplete="off">
|
||||
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button>
|
||||
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var btn = document.getElementById('id_buddy_btn');
|
||||
var panel = document.getElementById('id_buddy_panel');
|
||||
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_buddy_ok');
|
||||
var ok = document.getElementById('id_bud_ok');
|
||||
var html = document.documentElement;
|
||||
if (!btn || !panel || !input || !ok) return;
|
||||
|
||||
@@ -36,20 +36,20 @@
|
||||
}
|
||||
|
||||
function _open() {
|
||||
html.classList.add('buddy-open');
|
||||
html.classList.add('bud-open');
|
||||
btn.classList.add('active');
|
||||
setTimeout(function () { input.focus(); }, 60);
|
||||
}
|
||||
|
||||
function _close(opts) {
|
||||
opts = opts || {};
|
||||
html.classList.remove('buddy-open');
|
||||
html.classList.remove('bud-open');
|
||||
btn.classList.remove('active');
|
||||
if (opts.clear !== false) input.value = '';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (html.classList.contains('buddy-open')) {
|
||||
if (html.classList.contains('bud-open')) {
|
||||
_close();
|
||||
} else {
|
||||
_open();
|
||||
@@ -57,34 +57,34 @@
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close();
|
||||
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!html.classList.contains('buddy-open')) return;
|
||||
if (!html.classList.contains('bud-open')) return;
|
||||
if (panel.contains(e.target)) return;
|
||||
if (e.target === btn || btn.contains(e.target)) return;
|
||||
_close();
|
||||
});
|
||||
|
||||
function _appendBuddyEntry(buddy) {
|
||||
var list = document.getElementById('id_buddies_list');
|
||||
if (!list || !buddy) return;
|
||||
function _appendBudEntry(bud) {
|
||||
var list = document.getElementById('id_buds_list');
|
||||
if (!list || !bud) return;
|
||||
// Skip if already in DOM (server-side dedup ensures M2M idempotence;
|
||||
// this guards a fast double-click that races the post-add refresh).
|
||||
if (list.querySelector('[data-buddy-id="' + buddy.id + '"]')) return;
|
||||
if (list.querySelector('[data-bud-id="' + bud.id + '"]')) return;
|
||||
// Drop the empty-state row if present
|
||||
var empty = list.querySelector('.buddy-entry--empty');
|
||||
var empty = list.querySelector('.bud-entry--empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
var li = document.createElement('li');
|
||||
li.className = 'buddy-entry';
|
||||
li.dataset.buddyId = buddy.id;
|
||||
li.className = 'bud-entry';
|
||||
li.dataset.budId = bud.id;
|
||||
var name = document.createElement('span');
|
||||
name.className = 'buddy-name';
|
||||
name.textContent = buddy.username;
|
||||
name.className = 'bud-name';
|
||||
name.textContent = bud.username;
|
||||
li.appendChild(name);
|
||||
var buffer = list.querySelector('.buddy-entry-buffer');
|
||||
var buffer = list.querySelector('.bud-entry-buffer');
|
||||
if (buffer) list.insertBefore(li, buffer);
|
||||
else list.appendChild(li);
|
||||
}
|
||||
@@ -107,12 +107,12 @@
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||
.then(function (data) {
|
||||
if (data.buddy) _appendBuddyEntry(data.buddy);
|
||||
if (data.bud) _appendBudEntry(data.bud);
|
||||
_close({ clear: true });
|
||||
})
|
||||
.catch(function () {
|
||||
// Privacy-safe response shape — even an unregistered email is
|
||||
// a 200 w. {buddy: null}. Network/5xx land here; just close.
|
||||
// a 200 w. {bud: null}. Network/5xx land here; just close.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
{# _buddy_panel.html — bottom-left handshake btn + slide-out recipient #}
|
||||
{# _bud_panel.html — bottom-left handshake btn + slide-out recipient #}
|
||||
{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #}
|
||||
{# right). Included by post.html only. #}
|
||||
{# #}
|
||||
{# Spec lives in functional_tests/test_buddy_btn.py — write it red-first. #}
|
||||
{# Spec lives in functional_tests/test_bud_btn.py — write it red-first. #}
|
||||
{# Run: #}
|
||||
{# python src/manage.py test functional_tests.test_buddy_btn #}
|
||||
{# python src/manage.py test functional_tests.test_bud_btn #}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
|
||||
<button id="id_buddy_btn" type="button" aria-label="Share with a buddy">
|
||||
<button id="id_bud_btn" type="button" aria-label="Share with a bud">
|
||||
<i class="fa-solid fa-handshake"></i>
|
||||
</button>
|
||||
|
||||
<div id="id_buddy_panel"
|
||||
<div id="id_bud_panel"
|
||||
data-share-url="{% url 'billboard:share_post' post.id %}"
|
||||
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
|
||||
<input id="id_recipient"
|
||||
@@ -22,17 +22,17 @@
|
||||
type="email"
|
||||
placeholder="friend@example.com"
|
||||
autocomplete="off">
|
||||
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button>
|
||||
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var btn = document.getElementById('id_buddy_btn');
|
||||
var panel = document.getElementById('id_buddy_panel');
|
||||
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_buddy_ok');
|
||||
var ok = document.getElementById('id_bud_ok');
|
||||
var html = document.documentElement;
|
||||
if (!btn || !panel || !input || !ok) return;
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
|
||||
function _open() {
|
||||
html.classList.add('buddy-open');
|
||||
html.classList.add('bud-open');
|
||||
btn.classList.add('active');
|
||||
// small delay before focus so the slide-out animation can play
|
||||
setTimeout(function () { input.focus(); }, 60);
|
||||
@@ -50,13 +50,13 @@
|
||||
|
||||
function _close(opts) {
|
||||
opts = opts || {};
|
||||
html.classList.remove('buddy-open');
|
||||
html.classList.remove('bud-open');
|
||||
btn.classList.remove('active');
|
||||
if (opts.clear !== false) input.value = '';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (html.classList.contains('buddy-open')) {
|
||||
if (html.classList.contains('bud-open')) {
|
||||
_close();
|
||||
} else {
|
||||
_open();
|
||||
@@ -65,12 +65,12 @@
|
||||
|
||||
// Escape closes the panel, clears the field
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close();
|
||||
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
|
||||
});
|
||||
|
||||
// Click-outside dismiss — same pattern as game-kit.js
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!html.classList.contains('buddy-open')) return;
|
||||
if (!html.classList.contains('bud-open')) return;
|
||||
if (panel.contains(e.target)) return;
|
||||
if (e.target === btn || btn.contains(e.target)) return;
|
||||
_close();
|
||||
@@ -107,7 +107,7 @@
|
||||
else list.appendChild(li);
|
||||
}
|
||||
|
||||
// The shared-with header lives outside #id_buddy_panel — it's two <p>
|
||||
// The shared-with header lives outside #id_bud_panel — it's two <p>
|
||||
// siblings under .post-header. State transitions:
|
||||
// 0 → 1+ recipients : "just me, X" turns into
|
||||
// "shared between {chip}" + "& me, X"
|
||||
@@ -0,0 +1,4 @@
|
||||
{% load lyric_extras %}
|
||||
<li class="applet-list-entry bud-entry" data-bud-id="{{ item.id }}">
|
||||
<span class="bud-name">{{ item|display_name }}</span>
|
||||
</li>
|
||||
@@ -0,0 +1,3 @@
|
||||
<li class="applet-list-entry post-entry">
|
||||
<a href="{{ item.get_absolute_url }}">{{ item.title }}</a>
|
||||
</li>
|
||||
@@ -1,30 +0,0 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Dashbuddies{% endblock title_text %}
|
||||
{% block header_text %}<span>Dash</span>buddies{% endblock header_text %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="buddies-page">
|
||||
<header class="buddies-header">
|
||||
<h3 class="buddies-title">My Buddies</h3>
|
||||
</header>
|
||||
|
||||
<ul id="id_buddies_list" class="buddies-list">
|
||||
{% for buddy in buddies %}
|
||||
<li class="buddy-entry" data-buddy-id="{{ buddy.id }}">
|
||||
<span class="buddy-name">{{ buddy|display_name }}</span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="buddy-entry buddy-entry--empty">No buddies yet.</li>
|
||||
{% endfor %}
|
||||
<li class="buddy-entry-buffer" aria-hidden="true"></li>
|
||||
</ul>
|
||||
|
||||
{# Buddy btn (bottom-left) + slide-out add-buddy panel — async POST #}
|
||||
{# to add_buddy. Mirror of the post.html share buddy btn but distinct #}
|
||||
{# action (adds to User.buddies M2M) and DOM hooks (#id_buddies_list).#}
|
||||
{% include "apps/billboard/_partials/_buddy_add_panel.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
15
src/templates/apps/billboard/my_buds.html
Normal file
15
src/templates/apps/billboard/my_buds.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Billbuds{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>buds{% endblock header_text %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="applet-list-page applet-list-page--single">
|
||||
{% include "apps/applets/_partials/_applet-list-shell.html" with shell_title="My Buds" shell_list_id="id_buds_list" shell_items=buds shell_item_template="apps/billboard/_partials/_my_buds_item.html" shell_empty="No buds yet." %}
|
||||
</div>
|
||||
|
||||
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
|
||||
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
|
||||
{% endblock content %}
|
||||
@@ -1,20 +1,15 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Dashposts{% endblock title_text %}
|
||||
{% block header_text %}<span>Dash</span>posts{% endblock header_text %}
|
||||
{% block title_text %}Billposts{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>posts{% endblock header_text %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h3>{{ owner|display_name }}'s posts</h3>
|
||||
<ul>
|
||||
{% for post in owner.posts.all %}
|
||||
<li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h3>Posts shared with me</h3>
|
||||
<ul>
|
||||
{% for post in owner.shared_posts.all %}
|
||||
<li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{# Two applet-scroll sections — own posts + posts shared with me. #}
|
||||
{# Stack vertically in portrait, sit side-by-side in landscape (.--two-up).#}
|
||||
<div class="applet-list-page applet-list-page--two-up">
|
||||
{% include "apps/applets/_partials/_applet-list-shell.html" with shell_title=owner|display_name|add:" posts" shell_items=owner.posts.all shell_item_template="apps/billboard/_partials/_my_posts_item.html" shell_empty="No posts yet." %}
|
||||
{% include "apps/applets/_partials/_applet-list-shell.html" with shell_title="Shared with me" shell_items=owner.shared_posts.all shell_item_template="apps/billboard/_partials/_my_posts_item.html" shell_empty="Nothing shared yet." %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Dashpost{% endblock title_text %}
|
||||
{% block header_text %}<span>Dash</span>post{% endblock header_text %}
|
||||
{% block title_text %}Billpost{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>post{% endblock header_text %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
@@ -69,11 +69,11 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# Buddy btn (bottom-left) + slide-out recipient field — async share. #}
|
||||
{# Bud btn (bottom-left) + slide-out recipient field — async share. #}
|
||||
{# Suppressed on admin Posts (note unlock thread) since friend-invites #}
|
||||
{# don't apply to system-authored threads. #}
|
||||
{% if post.kind != 'note_unlock' %}
|
||||
{% include "apps/billboard/_partials/_buddy_panel.html" %}
|
||||
{% include "apps/billboard/_partials/_bud_panel.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
Reference in New Issue
Block a user