Compare commits
6 Commits
b3eb14140c
...
c426ca69fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c426ca69fa | ||
|
|
e0ace01670 | ||
|
|
eb0369f0b7 | ||
|
|
11ff109d1e | ||
|
|
246e45e55d | ||
|
|
5f6002aa70 |
@@ -0,0 +1,41 @@
|
|||||||
|
"""Rename the billboard `my-contacts` applet to `my-buddies` (slug + name).
|
||||||
|
|
||||||
|
User.buddies M2M (lyric/0004) lands at the same time; the applet links
|
||||||
|
to the new /billboard/my-buddies/ page where the user manages their
|
||||||
|
buddy list. "Contacts" was a placeholder name from the original
|
||||||
|
billboard scaffold.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-contacts")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-buddies"
|
||||||
|
applet.name = "My Buddies"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-buddies")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-contacts"
|
||||||
|
applet.name = "Contacts"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0005_seed_pronouns_applet"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, backward),
|
||||||
|
]
|
||||||
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
115
src/apps/billboard/static/apps/billboard/bud-autocomplete.js
Normal file
115
src/apps/billboard/static/apps/billboard/bud-autocomplete.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Bud-list autocomplete for #id_recipient inputs (post share panel + my_buds
|
||||||
|
// add panel). Mirrors the sky.html birth-place picker pattern: debounced
|
||||||
|
// fetch on input, top-3 suggestions rendered as buttons, click-to-fill,
|
||||||
|
// Escape closes, click-outside closes. No keyboard arrow/Enter cycling.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <div class="bud-panel-wrap">
|
||||||
|
// <input id="id_recipient" ...>
|
||||||
|
// <div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
|
||||||
|
// </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>
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var DEBOUNCE_MS = 250;
|
||||||
|
var MIN_CHARS = 1;
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.bindBudAutocomplete = function (input, suggestions, options) {
|
||||||
|
if (!input || !suggestions || !options || !options.searchUrl) return;
|
||||||
|
|
||||||
|
var debounceTimer = null;
|
||||||
|
var lastQuery = '';
|
||||||
|
|
||||||
|
function _hide() {
|
||||||
|
suggestions.hidden = true;
|
||||||
|
suggestions.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _render(buds) {
|
||||||
|
if (!buds || !buds.length) {
|
||||||
|
_hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestions.innerHTML = buds.map(function (b) {
|
||||||
|
// data-email + data-username so the click handler can fill the
|
||||||
|
// input with whichever the user originally typed (email if they
|
||||||
|
// started with `@`, else username).
|
||||||
|
return (
|
||||||
|
'<button type="button" class="bud-suggestion-item" ' +
|
||||||
|
'data-email="' + _esc(b.email) + '" ' +
|
||||||
|
'data-username="' + _esc(b.username) + '">' +
|
||||||
|
_esc(b.username) +
|
||||||
|
'</button>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
suggestions.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fetch(q) {
|
||||||
|
var url = options.searchUrl + '?q=' + encodeURIComponent(q);
|
||||||
|
fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function (data) {
|
||||||
|
// Drop late responses if the user has typed past this query.
|
||||||
|
if (input.value.trim() !== q) return;
|
||||||
|
_render(data.buds || []);
|
||||||
|
})
|
||||||
|
.catch(function () { _hide(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
lastQuery = q;
|
||||||
|
if (q.length < MIN_CHARS) { _hide(); return; }
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(function () { _fetch(q); }, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') _hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
suggestions.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.bud-suggestion-item');
|
||||||
|
if (!btn) return;
|
||||||
|
// Stop propagation so the bud-panel's document-level click-
|
||||||
|
// outside handler doesn't fire and close+clear the panel —
|
||||||
|
// _hide() about to detach the target makes a `sg.contains(e.target)`
|
||||||
|
// check at the document level unreliable.
|
||||||
|
e.stopPropagation();
|
||||||
|
// Fill w. whichever form the user was typing (email vs username).
|
||||||
|
// If the input value already contains '@', prefer email; else
|
||||||
|
// prefer username. This keeps the OK-submit semantics consistent
|
||||||
|
// w. what the user intended.
|
||||||
|
var typed = input.value.trim();
|
||||||
|
input.value = typed.indexOf('@') !== -1
|
||||||
|
? btn.dataset.email
|
||||||
|
: btn.dataset.username;
|
||||||
|
_hide();
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (suggestions.hidden) return;
|
||||||
|
if (suggestions.contains(e.target)) return;
|
||||||
|
if (e.target === input) return;
|
||||||
|
_hide();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}());
|
||||||
241
src/apps/billboard/tests/integrated/test_buds.py
Normal file
241
src/apps/billboard/tests/integrated/test_buds.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
def test_add_resolves_username_too_not_just_email(self):
|
||||||
|
"""Phase 2: recipient field accepts usernames as well as emails."""
|
||||||
|
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "alice"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["bud"]["username"], "alice")
|
||||||
|
self.assertIn(alice, self.user.buds.all())
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBudsViewTest(TestCase):
|
||||||
|
"""Top-3 prefix-match autocomplete endpoint backing #id_recipient."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.albert = User.objects.create(email="albert@test.io", username="albert")
|
||||||
|
self.alvin = User.objects.create(email="alvin@test.io", username="alvin")
|
||||||
|
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.user.buds.add(self.alice, self.albert, self.alvin, self.bob)
|
||||||
|
|
||||||
|
def test_username_prefix_match(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
usernames = [b["username"] for b in response.json()["buds"]]
|
||||||
|
# alice, albert, alvin all start with "al" — exactly 3 (cap)
|
||||||
|
self.assertEqual(len(usernames), 3)
|
||||||
|
self.assertIn("alice", usernames)
|
||||||
|
self.assertIn("albert", usernames)
|
||||||
|
self.assertIn("alvin", usernames)
|
||||||
|
self.assertNotIn("bob", usernames)
|
||||||
|
|
||||||
|
def test_caps_at_three_results(self):
|
||||||
|
d = User.objects.create(email="alfred@test.io", username="alfred")
|
||||||
|
self.user.buds.add(d)
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
self.assertEqual(len(response.json()["buds"]), 3)
|
||||||
|
|
||||||
|
def test_email_prefix_also_matches(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "bob@"})
|
||||||
|
usernames = [b["username"] for b in response.json()["buds"]]
|
||||||
|
self.assertIn("bob", usernames)
|
||||||
|
|
||||||
|
def test_does_not_leak_non_buds(self):
|
||||||
|
"""Non-buds (other registered users) don't appear in suggestions."""
|
||||||
|
User.objects.create(email="stranger@test.io", username="stranger")
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "str"})
|
||||||
|
self.assertEqual(response.json()["buds"], [])
|
||||||
|
|
||||||
|
def test_empty_q_returns_empty_list(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": ""})
|
||||||
|
self.assertEqual(response.json()["buds"], [])
|
||||||
|
|
||||||
|
def test_anon_redirects(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class SharePostImplicitAutoAddTest(TestCase):
|
||||||
|
"""Per-spec: when a share lands a recipient on Post.shared_with, the
|
||||||
|
sharer + recipient mutually auto-add each other to their buds lists."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.billboard.models import Post
|
||||||
|
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
|
||||||
|
self.client.force_login(self.sharer)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.post = Post.objects.create(owner=self.sharer)
|
||||||
|
|
||||||
|
def _share(self, recipient):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("billboard:share_post", args=[self.post.id]),
|
||||||
|
data={"recipient": recipient},
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_share_adds_recipient_to_sharer_buds(self):
|
||||||
|
self._share("alice@test.io")
|
||||||
|
self.assertIn(self.alice, self.sharer.buds.all())
|
||||||
|
|
||||||
|
def test_share_adds_sharer_to_recipient_buds(self):
|
||||||
|
"""Symmetric on shared events — recipient also gets the sharer."""
|
||||||
|
self._share("alice@test.io")
|
||||||
|
self.assertIn(self.sharer, self.alice.buds.all())
|
||||||
|
|
||||||
|
def test_share_with_username_also_auto_adds(self):
|
||||||
|
self._share("alice")
|
||||||
|
self.assertIn(self.alice, self.sharer.buds.all())
|
||||||
|
self.assertIn(self.sharer, self.alice.buds.all())
|
||||||
|
|
||||||
|
def test_unregistered_recipient_does_not_auto_add(self):
|
||||||
|
"""Privacy: unregistered email doesn't touch the buds graph."""
|
||||||
|
self._share("ghost@test.io")
|
||||||
|
self.assertEqual(self.sharer.buds.count(), 0)
|
||||||
@@ -13,7 +13,7 @@ from apps.lyric.models import User
|
|||||||
def _seed_billboard_applets():
|
def _seed_billboard_applets():
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buds", "My Buds", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
@@ -37,7 +37,7 @@ class BillboardViewTest(TestCase):
|
|||||||
self.assertIn("applets", response.context)
|
self.assertIn("applets", response.context)
|
||||||
slugs = [e["applet"].slug for e in response.context["applets"]]
|
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||||
self.assertIn("my-scrolls", slugs)
|
self.assertIn("my-scrolls", slugs)
|
||||||
self.assertIn("my-contacts", slugs)
|
self.assertIn("my-buds", slugs)
|
||||||
self.assertIn("most-recent-scroll", slugs)
|
self.assertIn("most-recent-scroll", slugs)
|
||||||
|
|
||||||
def test_passes_my_rooms_context(self):
|
def test_passes_my_rooms_context(self):
|
||||||
@@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
from apps.applets.models import UserApplet
|
from apps.applets.models import UserApplet
|
||||||
contacts = Applet.objects.get(slug="my-contacts")
|
contacts = Applet.objects.get(slug="my-buds")
|
||||||
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
||||||
self.assertFalse(ua.visible)
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
reverse("billboard:toggle_applets"),
|
reverse("billboard:toggle_applets"),
|
||||||
{"applets": [
|
{"applets": [
|
||||||
"my-scrolls",
|
"my-scrolls",
|
||||||
"my-contacts",
|
"my-buds",
|
||||||
"most-recent-scroll",
|
"most-recent-scroll",
|
||||||
]},
|
]},
|
||||||
HTTP_HX_REQUEST="true",
|
HTTP_HX_REQUEST="true",
|
||||||
@@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
|
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
|
||||||
|
|
||||||
def test_second_toggle_preserves_prior_hidden_state(self):
|
def test_second_toggle_preserves_prior_hidden_state(self):
|
||||||
# First toggle: hide Contacts only.
|
# First toggle: hide My Buds only.
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("billboard:toggle_applets"),
|
reverse("billboard:toggle_applets"),
|
||||||
{"applets": [
|
{"applets": [
|
||||||
@@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
]},
|
]},
|
||||||
HTTP_HX_REQUEST="true",
|
HTTP_HX_REQUEST="true",
|
||||||
)
|
)
|
||||||
# Second toggle: hide Most Recent Scroll additionally — Contacts must stay hidden.
|
# Second toggle: hide Most Recent Scroll additionally — My Buds must stay hidden.
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("billboard:toggle_applets"),
|
reverse("billboard:toggle_applets"),
|
||||||
{"applets": [
|
{"applets": [
|
||||||
@@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
HTTP_HX_REQUEST="true",
|
HTTP_HX_REQUEST="true",
|
||||||
)
|
)
|
||||||
from apps.applets.models import UserApplet
|
from apps.applets.models import UserApplet
|
||||||
contacts = Applet.objects.get(slug="my-contacts")
|
contacts = Applet.objects.get(slug="my-buds")
|
||||||
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ urlpatterns = [
|
|||||||
path("post/<uuid:post_id>/", views.view_post, name="view_post"),
|
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("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
|
||||||
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
||||||
|
path("my-buds/", views.my_buds, name="my_buds"),
|
||||||
|
path("buds/add", views.add_bud, name="add_bud"),
|
||||||
|
path("buds/search", views.search_buds, name="search_buds"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -298,9 +298,12 @@ def my_posts(request, user_id):
|
|||||||
return redirect("/")
|
return redirect("/")
|
||||||
if request.user.id != owner.id:
|
if request.user.id != owner.id:
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
|
handle = owner.username or owner.email
|
||||||
return render(request, "apps/billboard/my_posts.html", {
|
return render(request, "apps/billboard/my_posts.html", {
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
"page_class": "page-billboard",
|
"owner_posts_title": f"@{handle}'s Posts",
|
||||||
|
"others_posts_title": "Posts by Others",
|
||||||
|
"page_class": "page-billposts",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -308,12 +311,12 @@ def share_post(request, post_id):
|
|||||||
our_post = Post.objects.get(id=post_id)
|
our_post = Post.objects.get(id=post_id)
|
||||||
is_ajax = "application/json" in request.headers.get("Accept", "")
|
is_ajax = "application/json" in request.headers.get("Accept", "")
|
||||||
|
|
||||||
recipient_email = request.POST.get("recipient", "")
|
# Recipient may be email OR username — _resolve_recipient handles both
|
||||||
recipient = None
|
# (email if "@" present, else username lookup). The raw value is kept
|
||||||
try:
|
# for the Line text since users see what they typed in the per-line
|
||||||
recipient = User.objects.get(email=recipient_email)
|
# rendering (post-refresh + optimistic JS append).
|
||||||
except User.DoesNotExist:
|
recipient_email = (request.POST.get("recipient") or "").strip()
|
||||||
pass
|
recipient = _resolve_recipient(recipient_email)
|
||||||
|
|
||||||
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
|
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
|
||||||
if recipient is not None and recipient == request.user:
|
if recipient is not None and recipient == request.user:
|
||||||
@@ -329,6 +332,12 @@ def share_post(request, post_id):
|
|||||||
|
|
||||||
if recipient is not None and not is_reshare:
|
if recipient is not None and not is_reshare:
|
||||||
our_post.shared_with.add(recipient)
|
our_post.shared_with.add(recipient)
|
||||||
|
# Implicit auto-add to the buds graph — symmetric on shared events
|
||||||
|
# (per-spec): a share-event implies a mutual social link.
|
||||||
|
# `add()` is idempotent on M2M, no need to pre-check membership.
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
request.user.buds.add(recipient)
|
||||||
|
recipient.buds.add(request.user)
|
||||||
|
|
||||||
line = None
|
line = None
|
||||||
brief = None
|
brief = None
|
||||||
@@ -373,6 +382,86 @@ def share_post(request, post_id):
|
|||||||
return redirect(our_post)
|
return redirect(our_post)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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_buds(request):
|
||||||
|
return render(request, "apps/billboard/my_buds.html", {
|
||||||
|
"buds": request.user.buds.all(),
|
||||||
|
"page_class": "page-billbuds",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_recipient(raw):
|
||||||
|
"""Resolve a free-form recipient (email OR username) to a User, or None.
|
||||||
|
Email match takes precedence — if the input contains '@' we don't even
|
||||||
|
try the username lookup, so a username that happens to match an email
|
||||||
|
user's local part doesn't get coerced. Used by add_bud + share_post."""
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if "@" in raw:
|
||||||
|
try:
|
||||||
|
return User.objects.get(email__iexact=raw)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return User.objects.get(username__iexact=raw)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def add_bud(request):
|
||||||
|
if request.method != "POST":
|
||||||
|
from django.http import HttpResponseNotAllowed
|
||||||
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
|
||||||
|
candidate = _resolve_recipient(request.POST.get("recipient"))
|
||||||
|
|
||||||
|
bud = None
|
||||||
|
if candidate is not None and candidate != request.user:
|
||||||
|
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({"bud": bud})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def search_buds(request):
|
||||||
|
"""Top-3 prefix-match autocomplete pool for #id_recipient inputs.
|
||||||
|
Pulls only from request.user.buds — buds that haven't been added yet
|
||||||
|
don't appear in the autocomplete (privacy-by-default; new buds enter
|
||||||
|
the list via explicit add or implicit auto-add on share/invite).
|
||||||
|
Matches case-insensitive on either username or email prefix."""
|
||||||
|
from django.db.models import Q
|
||||||
|
q = (request.GET.get("q") or "").strip()
|
||||||
|
if not q:
|
||||||
|
return JsonResponse({"buds": []})
|
||||||
|
matches = (
|
||||||
|
request.user.buds
|
||||||
|
.filter(Q(username__istartswith=q) | Q(email__istartswith=q))
|
||||||
|
.order_by("username", "email")[:3]
|
||||||
|
)
|
||||||
|
return JsonResponse({"buds": [
|
||||||
|
{
|
||||||
|
"id": str(b.id),
|
||||||
|
"username": b.username or b.email,
|
||||||
|
"email": b.email,
|
||||||
|
}
|
||||||
|
for b in matches
|
||||||
|
]})
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def save_scroll_position(request, room_id):
|
def save_scroll_position(request, room_id):
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
|
|||||||
@@ -83,6 +83,6 @@ const Note = Brief;
|
|||||||
|
|
||||||
// `const Brief = (...)` at script-tag scope is reachable as a bare name but
|
// `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
|
// 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.Brief = Brief;
|
||||||
window.Note = Note;
|
window.Note = Note;
|
||||||
|
|||||||
@@ -293,21 +293,31 @@ class Note(models.Model):
|
|||||||
post.title = NOTE_UNLOCK_POST_TITLE
|
post.title = NOTE_UNLOCK_POST_TITLE
|
||||||
post.save(update_fields=["title"])
|
post.save(update_fields=["title"])
|
||||||
|
|
||||||
username = user.username or user.email
|
# Bare-email fallback when user.username is None (no `@` prefix —
|
||||||
|
# the address already carries one). When username is set, use the
|
||||||
|
# `@handle` form. Both wrapped in .post-attribution so the CSS
|
||||||
|
# palette key (--quaUser) lights up the username + title combo.
|
||||||
|
handle = f"@{user.username}" if user.username else user.email
|
||||||
note_anchor = (
|
note_anchor = (
|
||||||
f'<a class="note-ref" href="/billboard/my-notes/">'
|
f'<a class="note-ref" href="/billboard/my-notes/">'
|
||||||
f'{note.display_name}</a>'
|
f'{note.display_name}</a>'
|
||||||
)
|
)
|
||||||
|
attr_handle = f'<span class="post-attribution">{handle}</span>'
|
||||||
|
attr_title = f'<span class="post-attribution">{note.display_title}</span>'
|
||||||
if slug in _ADMIN_NOTE_SLUGS:
|
if slug in _ADMIN_NOTE_SLUGS:
|
||||||
line_text = (
|
line_text = (
|
||||||
f"The administration recognizes {username} for {note_anchor}, "
|
f"The administration recognizes {attr_handle} for {note_anchor}, "
|
||||||
f"which comes with the customary title of {note.display_title}. "
|
f"which bestows the honorary title of {attr_title}. "
|
||||||
"This does not entail any additional benefits."
|
"This does not entail any additional corporate benefits."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
attr_combo = (
|
||||||
|
f'<span class="post-attribution">{handle} '
|
||||||
|
f'the {note.display_title}</span>'
|
||||||
|
)
|
||||||
line_text = (
|
line_text = (
|
||||||
f"Look!—new Note unlocked. {note_anchor} "
|
f"Look!—new Note unlocked. {note_anchor} "
|
||||||
f"recognizes {username} the {note.display_title}."
|
f"recognizes {attr_combo}."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Lazy get-or-create: TransactionTestCase flushes the migration-seeded
|
# Lazy get-or-create: TransactionTestCase flushes the migration-seeded
|
||||||
|
|||||||
20
src/apps/lyric/migrations/0004_user_buddies.py
Normal file
20
src/apps/lyric/migrations/0004_user_buddies.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("lyric", "0003_seed_adman"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="buddies",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="added_as_buddy",
|
||||||
|
to="lyric.user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
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,6 +113,14 @@ class User(AbstractBaseUser):
|
|||||||
unlocked_decks = models.ManyToManyField(
|
unlocked_decks = models.ManyToManyField(
|
||||||
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
||||||
)
|
)
|
||||||
|
# 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 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(
|
active_title = models.ForeignKey(
|
||||||
"drama.Note", null=True, blank=True,
|
"drama.Note", null=True, blank=True,
|
||||||
on_delete=models.SET_NULL, related_name="+",
|
on_delete=models.SET_NULL, related_name="+",
|
||||||
|
|||||||
@@ -48,3 +48,16 @@ def display_name(user):
|
|||||||
if user.username:
|
if user.username:
|
||||||
return user.username
|
return user.username
|
||||||
return truncate_email(user.email)
|
return truncate_email(user.email)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
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:
|
||||||
|
return ""
|
||||||
|
if user.username:
|
||||||
|
return f"@{user.username}"
|
||||||
|
return truncate_email(user.email)
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ class PostPage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def share_post_with(self, email):
|
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
|
# type the email in the slide-out, click the .btn-confirm OK, wait
|
||||||
# for the recipient chip.
|
# for the recipient chip.
|
||||||
buddy_btn = self.test.browser.find_element(By.ID, "id_buddy_btn")
|
bud_btn = self.test.browser.find_element(By.ID, "id_bud_btn")
|
||||||
buddy_btn.click()
|
bud_btn.click()
|
||||||
recipient = self.test.wait_for(
|
recipient = self.test.wait_for(
|
||||||
lambda: self.test.browser.find_element(By.ID, "id_recipient")
|
lambda: self.test.browser.find_element(By.ID, "id_recipient")
|
||||||
)
|
)
|
||||||
recipient.send_keys(email)
|
recipient.send_keys(email)
|
||||||
ok = self.test.browser.find_element(
|
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()
|
ok.click()
|
||||||
self.test.wait_for(
|
self.test.wait_for(
|
||||||
|
|||||||
@@ -59,21 +59,21 @@ class AdminPostInputReadonlyTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdminPostHasNoBuddyBtnTest(FunctionalTest):
|
class AdminPostHasNoBudBtnTest(FunctionalTest):
|
||||||
"""Admin-Post (note-unlock thread) suppresses #id_buddy_btn — friend
|
"""Admin-Post (note-unlock thread) suppresses #id_bud_btn — friend
|
||||||
invites don't apply to system-authored threads. User-Post still
|
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):
|
def setUp(self):
|
||||||
super().setUp()
|
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")
|
Note.grant_if_new(self.gamer, "stargazer")
|
||||||
self.admin_post = Post.objects.get(
|
self.admin_post = Post.objects.get(
|
||||||
owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK,
|
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.browser.get(
|
||||||
self.live_server_url + f"/billboard/post/{self.admin_post.id}/"
|
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")
|
lambda: self.browser.find_element(By.ID, "id_post_line_text")
|
||||||
)
|
)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
self.browser.find_elements(By.ID, "id_buddy_btn"),
|
self.browser.find_elements(By.ID, "id_bud_btn"),
|
||||||
"Admin-Post must NOT render #id_buddy_btn",
|
"Admin-Post must NOT render #id_bud_btn",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buds", "My Buds", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
@@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest):
|
|||||||
class BillboardAppletsTest(FunctionalTest):
|
class BillboardAppletsTest(FunctionalTest):
|
||||||
"""
|
"""
|
||||||
FT: billboard page renders three applets in the grid — My Scrolls,
|
FT: billboard page renders three applets in the grid — My Scrolls,
|
||||||
My Contacts, and Most Recent Scroll — with a functioning gear menu.
|
My Buds, and Most Recent Scroll — with a functioning gear menu.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buds", "My Buds", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
@@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||||
)
|
)
|
||||||
self.browser.find_element(By.ID, "id_applet_my_contacts")
|
self.browser.find_element(By.ID, "id_applet_my_buds")
|
||||||
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
||||||
|
|
||||||
def test_billboard_my_scrolls_lists_rooms(self):
|
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")
|
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
||||||
)
|
)
|
||||||
contacts_cb = menu.find_element(
|
contacts_cb = menu.find_element(
|
||||||
By.CSS_SELECTOR, "input[value='my-contacts']"
|
By.CSS_SELECTOR, "input[value='my-buds']"
|
||||||
)
|
)
|
||||||
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
||||||
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
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)
|
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertEqual(
|
lambda: self.assertEqual(
|
||||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
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)
|
# The freshly-rendered menu must reflect DB state (Contacts unchecked)
|
||||||
contacts_cb = menu.find_element(
|
contacts_cb = menu.find_element(
|
||||||
By.CSS_SELECTOR, "input[value='my-contacts']"
|
By.CSS_SELECTOR, "input[value='my-buds']"
|
||||||
)
|
)
|
||||||
self.assertFalse(contacts_cb.is_selected())
|
self.assertFalse(contacts_cb.is_selected())
|
||||||
most_recent_scroll_cb = menu.find_element(
|
most_recent_scroll_cb = menu.find_element(
|
||||||
@@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
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")
|
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
self.browser.find_elements(By.ID, "id_applet_my_buds"),
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
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
|
Written red BEFORE implementation as a TDD handoff so the post-compaction
|
||||||
agent (or future Disco) can land the feature without losing intent. Run:
|
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.
|
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
|
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
|
of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same
|
||||||
fixed/circular/secUser-bordered look + .active-state styling.
|
fixed/circular/secUser-bordered look + .active-state styling.
|
||||||
• Lives in a partial template, e.g. apps/billboard/_partials/_buddy_panel.html,
|
• Lives in a partial template, e.g. apps/billboard/_partials/_bud_panel.html,
|
||||||
included only by post.html (NOT the global base.html — buddy is post-only).
|
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
|
• Replaces the inline share form on post.html: typing the recipient now
|
||||||
happens in a slide-out under the buddy btn.
|
happens in a slide-out under the bud btn.
|
||||||
• Click #id_buddy_btn → recipient field grows L→R under it, spanning
|
• Click #id_bud_btn → recipient field grows L→R under it, spanning
|
||||||
`100vw - 3rem` (1.5rem padding each side). The field is vertically
|
`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.
|
text + placeholder don't overlap the btn glyph.
|
||||||
• An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the
|
• An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the
|
||||||
field (replaces the legacy big SHARE .btn-primary).
|
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,
|
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
|
• Click OK → POST share-post async (existing C3.b endpoint), clears the
|
||||||
field, closes the slide-out, slide-down Brief banner appears.
|
field, closes the slide-out, slide-down Brief banner appears.
|
||||||
• Click outside the field/btn → closes the slide-out, clears the field
|
• Click outside the field/btn → closes the slide-out, clears the field
|
||||||
@@ -40,29 +40,29 @@ SPEC SUMMARY
|
|||||||
IMPLEMENTATION CHECKLIST (post-compaction)
|
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.
|
+ slide-out form + inline JS.
|
||||||
2. Edit templates/apps/billboard/post.html:
|
2. Edit templates/apps/billboard/post.html:
|
||||||
- Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn
|
- Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn
|
||||||
block (the share JS moves into _buddy_panel.html).
|
block (the share JS moves into _bud_panel.html).
|
||||||
- Add `{% include "apps/billboard/_partials/_buddy_panel.html" %}`.
|
- Add `{% include "apps/billboard/_partials/_bud_panel.html" %}`.
|
||||||
3. Edit billboard.views.view_post (or my_posts) context:
|
3. Edit billboard.views.view_post (or my_posts) context:
|
||||||
- "page_class": "page-billboard" (or new page-post) so the body class
|
- "page_class": "page-billboard" (or new page-post) so the body class
|
||||||
picks up the aperture SCSS.
|
picks up the aperture SCSS.
|
||||||
4. SCSS — add to _game-kit.scss neighbour or new _buddy.scss:
|
4. SCSS — add to _game-kit.scss neighbour or new _bud.scss:
|
||||||
- #id_buddy_btn: position fixed bottom-left, mirror of #id_kit_btn
|
- #id_bud_btn: position fixed bottom-left, mirror of #id_kit_btn
|
||||||
(3rem circle, secUser border, .active state, etc.)
|
(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
|
left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when
|
||||||
kit btn visible — but mutual-exclusion makes that moot), bottom-
|
kit btn visible — but mutual-exclusion makes that moot), bottom-
|
||||||
aligned w. the btn centre, transition transform/width L→R.
|
aligned w. the btn centre, transition transform/width L→R.
|
||||||
- html.buddy-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
|
- html.bud-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
|
||||||
- html.kit-open #id_buddy_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 OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on
|
||||||
the trailing edge of the slide-out.
|
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.
|
- 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,
|
- On submit/OK: fetch POST share-post w. Accept:application/json,
|
||||||
reuse the C3.b response handling (line append, banner via
|
reuse the C3.b response handling (line append, banner via
|
||||||
Brief.showBanner, recipient_display chip append).
|
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
|
6. post.html + my_posts.html: add the body class hook so the aperture
|
||||||
SCSS engages (probably page-billboard already, just need to confirm).
|
SCSS engages (probably page-billboard already, just need to confirm).
|
||||||
7. Update functional_tests.post_page.PostPage.share_post_with() to
|
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-
|
8. Re-run test_sharing.SharingTest — should still pass once the page-
|
||||||
object mirrors the new flow.
|
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,
|
- The C3.b share-post async endpoint accepts Accept: application/json,
|
||||||
returns {brief, line_text, recipient_display}; intercepted by the
|
returns {brief, line_text, recipient_display}; intercepted by the
|
||||||
inline JS in post.html's existing #id_share_form. That JS moves into
|
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
|
- body.page-billboard class is set by billboard:billboard view; post.html
|
||||||
needs it (or its own class) added in billboard.views.view_post.
|
needs it (or its own class) added in billboard.views.view_post.
|
||||||
"""
|
"""
|
||||||
@@ -104,8 +104,8 @@ def _seed_a_post(user):
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
class BuddyBtnPresenceTest(FunctionalTest):
|
class BudBtnPresenceTest(FunctionalTest):
|
||||||
"""The buddy btn is post-only — present on post.html, absent elsewhere."""
|
"""The bud btn is post-only — present on post.html, absent elsewhere."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@@ -113,46 +113,46 @@ class BuddyBtnPresenceTest(FunctionalTest):
|
|||||||
slug="my-posts",
|
slug="my-posts",
|
||||||
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
|
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.post = _seed_a_post(self.gamer)
|
||||||
self.create_pre_authenticated_session("buddy@test.io")
|
self.create_pre_authenticated_session("bud@test.io")
|
||||||
|
|
||||||
# ── B1 ──────────────────────────────────────────────────────────────────
|
# ── 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}/")
|
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")
|
icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake")
|
||||||
self.assertIsNotNone(icon)
|
self.assertIsNotNone(icon)
|
||||||
|
|
||||||
# ── B2 ──────────────────────────────────────────────────────────────────
|
# ── B2 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_buddy_btn_absent_on_dashboard(self):
|
def test_bud_btn_absent_on_dashboard(self):
|
||||||
self.browser.get(self.live_server_url + "/")
|
self.browser.get(self.live_server_url + "/")
|
||||||
# Allow page to settle
|
# Allow page to settle
|
||||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
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 ──────────────────────────────────────────────────────────────────
|
# ── 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.browser.get(self.live_server_url + "/billboard/")
|
||||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
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."""
|
"""The btn sits bottom-left, mirror of #id_kit_btn's bottom-right."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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.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}/")
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
def test_buddy_btn_is_fixed_bottom_left(self):
|
def test_bud_btn_is_fixed_bottom_left(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"))
|
||||||
cs = self.browser.execute_script(
|
cs = self.browser.execute_script(
|
||||||
"var s = getComputedStyle(arguments[0]); "
|
"var s = getComputedStyle(arguments[0]); "
|
||||||
"return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};",
|
"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["bottom"], "auto")
|
||||||
self.assertNotEqual(cs["left"], "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."""
|
"""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")
|
kit = self.browser.find_element(By.ID, "id_kit_btn")
|
||||||
b_box = btn.size
|
b_box = btn.size
|
||||||
k_box = kit.size
|
k_box = kit.size
|
||||||
@@ -174,47 +174,47 @@ class BuddyBtnPositionTest(FunctionalTest):
|
|||||||
self.assertEqual(b_box["height"], k_box["height"])
|
self.assertEqual(b_box["height"], k_box["height"])
|
||||||
|
|
||||||
|
|
||||||
class BuddyBtnSlideOutTest(FunctionalTest):
|
class BudBtnSlideOutTest(FunctionalTest):
|
||||||
"""Click the buddy btn → recipient field + OK btn slide out under it."""
|
"""Click the bud btn → recipient field + OK btn slide out under it."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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.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}/")
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
def test_recipient_field_hidden_until_click(self):
|
def test_recipient_field_hidden_until_click(self):
|
||||||
"""Pre-click: the field is in DOM but visually closed (e.g. width 0
|
"""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
|
or transform scaleX(0)) — assertion checks it doesn't take its full
|
||||||
viewport-spanning width yet."""
|
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
|
# 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
|
# be in a closed state — assert the panel container exists and the
|
||||||
# input is not displayed at full width (a CSS-driven slide-out).
|
# 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)
|
# Before click, panel visible-width should be < viewport / 2 (closed)
|
||||||
viewport_w = self.browser.execute_script("return window.innerWidth;")
|
viewport_w = self.browser.execute_script("return window.innerWidth;")
|
||||||
self.assertLess(panel.size["width"], viewport_w / 2)
|
self.assertLess(panel.size["width"], viewport_w / 2)
|
||||||
|
|
||||||
def test_click_buddy_btn_reveals_recipient_field_and_ok_btn(self):
|
def test_click_bud_btn_reveals_recipient_field_and_ok_btn(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()
|
btn.click()
|
||||||
|
|
||||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
self.assertTrue(recipient.is_displayed())
|
self.assertTrue(recipient.is_displayed())
|
||||||
|
|
||||||
# OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE
|
# 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")
|
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"))
|
self.assertIn("active", btn.get_attribute("class"))
|
||||||
|
|
||||||
def test_panel_spans_almost_full_viewport_when_open(self):
|
def test_panel_spans_almost_full_viewport_when_open(self):
|
||||||
"""When open, the panel spans 100vw - 3rem (1.5rem each side)."""
|
"""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()
|
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
|
# Wait for the slide-out transition to settle
|
||||||
self.wait_for(lambda: self.assertGreater(
|
self.wait_for(lambda: self.assertGreater(
|
||||||
panel.size["width"],
|
panel.size["width"],
|
||||||
@@ -222,32 +222,32 @@ class BuddyBtnSlideOutTest(FunctionalTest):
|
|||||||
))
|
))
|
||||||
|
|
||||||
def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self):
|
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()
|
btn.click()
|
||||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
pad = self.browser.execute_script(
|
pad = self.browser.execute_script(
|
||||||
"return parseFloat(getComputedStyle(arguments[0]).paddingLeft);",
|
"return parseFloat(getComputedStyle(arguments[0]).paddingLeft);",
|
||||||
recipient,
|
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.
|
# overlap the placeholder/typed text.
|
||||||
self.assertGreaterEqual(pad, 32)
|
self.assertGreaterEqual(pad, 32)
|
||||||
|
|
||||||
|
|
||||||
class BuddyKitMutualExclusionTest(FunctionalTest):
|
class BudKitMutualExclusionTest(FunctionalTest):
|
||||||
"""When kit btn is active, buddy btn fades to 0 — and vice-versa."""
|
"""When kit btn is active, bud btn fades to 0 — and vice-versa."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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.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}/")
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
def test_buddy_active_fades_kit_btn(self):
|
def test_bud_active_fades_kit_btn(self):
|
||||||
buddy = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
bud = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||||
kit = self.browser.find_element(By.ID, "id_kit_btn")
|
kit = self.browser.find_element(By.ID, "id_kit_btn")
|
||||||
buddy.click()
|
bud.click()
|
||||||
self.wait_for(lambda: self.assertEqual(
|
self.wait_for(lambda: self.assertEqual(
|
||||||
self.browser.execute_script(
|
self.browser.execute_script(
|
||||||
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
||||||
@@ -256,32 +256,32 @@ class BuddyKitMutualExclusionTest(FunctionalTest):
|
|||||||
0.0,
|
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"))
|
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()
|
kit.click()
|
||||||
self.wait_for(lambda: self.assertEqual(
|
self.wait_for(lambda: self.assertEqual(
|
||||||
self.browser.execute_script(
|
self.browser.execute_script(
|
||||||
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
||||||
buddy,
|
bud,
|
||||||
),
|
),
|
||||||
0.0,
|
0.0,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
class BuddyBtnDismissTest(FunctionalTest):
|
class BudBtnDismissTest(FunctionalTest):
|
||||||
"""Click outside / Escape closes the panel; field is cleared; reopening
|
"""Click outside / Escape closes the panel; field is cleared; reopening
|
||||||
shows the placeholder, not the previously-typed value."""
|
shows the placeholder, not the previously-typed value."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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.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}/")
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
def _open_and_type(self, text):
|
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()
|
btn.click()
|
||||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
recipient.send_keys(text)
|
recipient.send_keys(text)
|
||||||
@@ -310,24 +310,24 @@ class BuddyBtnDismissTest(FunctionalTest):
|
|||||||
self.assertEqual(recipient.get_attribute("value"), "")
|
self.assertEqual(recipient.get_attribute("value"), "")
|
||||||
|
|
||||||
|
|
||||||
class BuddyBtnOkSubmitsAsyncShareTest(FunctionalTest):
|
class BudBtnOkSubmitsAsyncShareTest(FunctionalTest):
|
||||||
"""OK → POST share-post (Accept:application/json) → Brief banner +
|
"""OK → POST share-post (Accept:application/json) → Brief banner +
|
||||||
recipient chip appended; field clears; panel closes."""
|
recipient chip appended; field clears; panel closes."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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.recipient = User.objects.create(email="alice@test.io")
|
||||||
self.post = _seed_a_post(self.sharer)
|
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}/")
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
def test_ok_creates_brief_appends_line_and_chip(self):
|
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()
|
btn.click()
|
||||||
recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
recipient_input.send_keys("alice@test.io")
|
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()
|
ok.click()
|
||||||
|
|
||||||
# 1. Brief is created server-side
|
# 1. Brief is created server-side
|
||||||
@@ -358,9 +358,9 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
|
|||||||
slug="my-posts",
|
slug="my-posts",
|
||||||
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
|
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.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):
|
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}/")
|
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
|
# Billboard applets — page renders blank without these
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buds", "My Buds", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
|
|||||||
100
src/functional_tests/test_my_buds.py
Normal file
100
src/functional_tests/test_my_buds.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""FT for the My Buds page — bud btn + slide-out add flow.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import 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_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, ".bud-entry .bud-name")
|
||||||
|
)
|
||||||
|
self.assertEqual(entry.text, "alice")
|
||||||
|
|
||||||
|
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, ".applet-list-entry--empty")
|
||||||
|
)
|
||||||
|
self.assertIn("No buds yet", empty.text)
|
||||||
|
|
||||||
|
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_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".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.buds.all())
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_autocomplete_suggests_buds_by_username_prefix(self):
|
||||||
|
"""Phase 2: typing in #id_recipient pulls top-3 prefix matches from
|
||||||
|
request.user.buds and renders them as .bud-suggestion-item buttons.
|
||||||
|
Click → input.value fills with the bud's username (or email if the
|
||||||
|
user typed an `@` already)."""
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.gamer.buds.add(bob)
|
||||||
|
|
||||||
|
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("al")
|
||||||
|
|
||||||
|
suggestions = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_bud_suggestions .bud-suggestion-item"
|
||||||
|
))
|
||||||
|
self.assertEqual(suggestions.text.strip(), "alice")
|
||||||
|
|
||||||
|
suggestions.click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
recipient.get_attribute("value"), "alice"
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_add_unregistered_email_is_silent_noop(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("ghost@test.io")
|
||||||
|
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 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)
|
||||||
@@ -41,7 +41,7 @@ class SharingTest(FunctionalTest):
|
|||||||
share_box = post_page.get_share_box()
|
share_box = post_page.get_share_box()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
share_box.get_attribute("placeholder"),
|
share_box.get_attribute("placeholder"),
|
||||||
"friend@example.com",
|
"friend@example.com or username",
|
||||||
)
|
)
|
||||||
|
|
||||||
post_page.share_post_with("alice@test.io")
|
post_page.share_post_with("alice@test.io")
|
||||||
|
|||||||
@@ -35,13 +35,17 @@
|
|||||||
|
|
||||||
html:has(body.page-billboard),
|
html:has(body.page-billboard),
|
||||||
html:has(body.page-billscroll),
|
html:has(body.page-billscroll),
|
||||||
html:has(body.page-billpost) {
|
html:has(body.page-billpost),
|
||||||
|
html:has(body.page-billbuds),
|
||||||
|
html:has(body.page-billposts) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.page-billboard,
|
body.page-billboard,
|
||||||
body.page-billscroll,
|
body.page-billscroll,
|
||||||
body.page-billpost {
|
body.page-billpost,
|
||||||
|
body.page-billbuds,
|
||||||
|
body.page-billposts {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -126,6 +130,14 @@ body.page-billpost {
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
// Username + title attribution spans — line author column, self/shared
|
||||||
|
// header lines, server-rendered grant prose. --quaUser palette key
|
||||||
|
// unifies them across the page; placed at .post-page scope so it
|
||||||
|
// applies in BOTH .post-header and #id_post_table descendants.
|
||||||
|
.post-attribution {
|
||||||
|
color: rgba(var(--quaUser), 1);
|
||||||
|
}
|
||||||
|
|
||||||
.post-header {
|
.post-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
@@ -165,7 +177,7 @@ body.page-billpost {
|
|||||||
|
|
||||||
.post-line-author {
|
.post-line-author {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
opacity: 0.75;
|
color: rgba(var(--quaUser), 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -215,6 +227,72 @@ body.page-billpost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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).
|
||||||
|
|
||||||
|
.applet-list-page {
|
||||||
|
@extend %billboard-page-base;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
.applet-scroll {
|
||||||
|
@extend %applet-box;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.applet-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.75rem 0 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applet-list-entry {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
|
||||||
|
.bud-name { font-weight: bold; opacity: 0.85; }
|
||||||
|
|
||||||
|
&--empty { opacity: 0.6; font-style: italic; }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--terUser), 1);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: text-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
color: rgba(var(--ninUser), 1);
|
||||||
|
text-shadow: 0 0 0.55rem rgba(var(--terUser), 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.applet-list-buffer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side-by-side in landscape; stacked in portrait (default).
|
||||||
|
&--two-up {
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
flex-direction: row;
|
||||||
|
.applet-scroll { flex: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Billboard applet placement ─────────────────────────────────────────────
|
// ── Billboard applet placement ─────────────────────────────────────────────
|
||||||
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
|
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
|
||||||
// Right column (8-wide): Most Recent Scroll spans full height.
|
// Right column (8-wide): Most Recent Scroll spans full height.
|
||||||
@@ -222,13 +300,13 @@ body.page-billpost {
|
|||||||
|
|
||||||
#id_billboard_applets_container {
|
#id_billboard_applets_container {
|
||||||
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
|
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
|
||||||
#id_applet_my_contacts { 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_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; }
|
#id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; }
|
||||||
|
|
||||||
@container (max-width: 550px) {
|
@container (max-width: 550px) {
|
||||||
#id_applet_my_scrolls,
|
#id_applet_my_scrolls,
|
||||||
#id_applet_my_contacts,
|
#id_applet_my_buds,
|
||||||
#id_applet_notes,
|
#id_applet_notes,
|
||||||
#id_applet_most_recent_scroll {
|
#id_applet_most_recent_scroll {
|
||||||
grid-column: 1 / span 12;
|
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
|
// 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
|
// 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.
|
// 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;
|
position: fixed;
|
||||||
bottom: 0.5rem;
|
bottom: 0.5rem;
|
||||||
left: 0.5rem;
|
left: 0.5rem;
|
||||||
@@ -43,12 +43,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Slide-out panel: collapsed by default; opens to span ~viewport - 3rem.
|
// Slide-out panel: collapsed by default; opens to span ~viewport - 3rem.
|
||||||
#id_buddy_panel {
|
#id_bud_panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0.5rem; // align bottom edge w. buddy btn
|
bottom: 0.5rem; // align bottom edge w. bud btn
|
||||||
left: 1.5rem;
|
left: 1.5rem;
|
||||||
right: 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;
|
z-index: 317;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
// Closed state — collapse leftward into the buddy btn
|
// Closed state — collapse leftward into the bud btn
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
transform: scaleX(0);
|
transform: scaleX(0);
|
||||||
transition: transform 0.2s ease-out, opacity 0.15s ease;
|
transition: transform 0.2s ease-out, opacity 0.15s ease;
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 100%;
|
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.
|
// at left:1.5rem) doesn't visually overlap the placeholder/typed text.
|
||||||
padding: 0 1rem 0 3.5rem;
|
padding: 0 1rem 0 3.5rem;
|
||||||
background-color: rgba(var(--priUser), 1);
|
background-color: rgba(var(--priUser), 1);
|
||||||
@@ -97,9 +97,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// html.buddy-open: slide the panel out, fade the kit btn away.
|
// html.bud-open: slide the panel out, fade the kit btn away.
|
||||||
html.buddy-open {
|
html.bud-open {
|
||||||
#id_buddy_panel {
|
#id_bud_panel {
|
||||||
transform: scaleX(1);
|
transform: scaleX(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -111,10 +111,61 @@ 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
|
// 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.
|
// 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;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
.bud-suggestions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 4rem; // panel bottom (0.5rem) + height (3rem) + gap (0.5rem)
|
||||||
|
left: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 320; // above the panel itself
|
||||||
|
background: rgba(var(--priUser), 1);
|
||||||
|
border: 0.1rem solid rgba(var(--terUser), 0.3);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 10rem;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bud-suggestion-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 0.05rem solid rgba(var(--terUser), 0.1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(var(--ninUser), 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.35;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background: rgba(var(--terUser), 0.12);
|
||||||
|
color: rgba(var(--ninUser), 1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
@import 'note';
|
@import 'note';
|
||||||
@import 'tooltips';
|
@import 'tooltips';
|
||||||
@import 'game-kit';
|
@import 'game-kit';
|
||||||
@import 'buddy';
|
@import 'bud';
|
||||||
@import 'wallet-tokens';
|
@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
|
<section
|
||||||
id="id_applet_my_contacts"
|
id="id_applet_my_buds"
|
||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
>
|
>
|
||||||
<h2>Contacts</h2>
|
<h2><a href="{% url 'billboard:my_buds' %}" class="my-buds-main">My Buds</a></h2>
|
||||||
{% include "core/_partials/_forthcoming.html" %}
|
{% include "core/_partials/_forthcoming.html" %}
|
||||||
</section>
|
</section>
|
||||||
143
src/templates/apps/billboard/_partials/_bud_add_panel.html
Normal file
143
src/templates/apps/billboard/_partials/_bud_add_panel.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{% load static %}
|
||||||
|
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||||
|
{# _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_bud_btn" type="button" aria-label="Add a bud">
|
||||||
|
<i class="fa-solid fa-handshake"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="id_bud_panel" data-add-url="{% url 'billboard:add_bud' %}">
|
||||||
|
<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 list — sibling of #id_bud_panel because the #}
|
||||||
|
{# panel has overflow:hidden for its scaleX slide animation. #}
|
||||||
|
<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 (panel has overflow:hidden
|
||||||
|
// for its scaleX slide); a click inside them must NOT close+clear.
|
||||||
|
var sg = document.getElementById('id_bud_suggestions');
|
||||||
|
if (sg && sg.contains(e.target)) return;
|
||||||
|
_close();
|
||||||
|
});
|
||||||
|
|
||||||
|
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-bud-id="' + bud.id + '"]')) return;
|
||||||
|
// Drop the empty-state row if present
|
||||||
|
var empty = list.querySelector('.bud-entry--empty');
|
||||||
|
if (empty) empty.remove();
|
||||||
|
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'bud-entry';
|
||||||
|
li.dataset.budId = bud.id;
|
||||||
|
var name = document.createElement('span');
|
||||||
|
name.className = 'bud-name';
|
||||||
|
name.textContent = bud.username;
|
||||||
|
li.appendChild(name);
|
||||||
|
var buffer = list.querySelector('.bud-entry-buffer');
|
||||||
|
if (buffer) list.insertBefore(li, buffer);
|
||||||
|
else list.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
ok.addEventListener('click', function () {
|
||||||
|
var email = input.value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.set('recipient', email);
|
||||||
|
|
||||||
|
fetch(panel.dataset.addUrl, {
|
||||||
|
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 (data.bud) _appendBudEntry(data.bud);
|
||||||
|
_close({ clear: true });
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Privacy-safe response shape — even an unregistered email is
|
||||||
|
// a 200 w. {bud: null}. Network/5xx land here; just close.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
ok.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
@@ -1,38 +1,51 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load lyric_extras %}
|
{% 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- #}
|
{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #}
|
||||||
{# right). Included by post.html only. #}
|
{# 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: #}
|
{# 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>
|
<i class="fa-solid fa-handshake"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="id_buddy_panel"
|
<div id="id_bud_panel"
|
||||||
data-share-url="{% url 'billboard:share_post' post.id %}"
|
data-share-url="{% url 'billboard:share_post' post.id %}"
|
||||||
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
|
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
|
||||||
<input id="id_recipient"
|
<input id="id_recipient"
|
||||||
name="recipient"
|
name="recipient"
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="friend@example.com"
|
placeholder="friend@example.com or username"
|
||||||
autocomplete="off">
|
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>
|
</div>
|
||||||
|
|
||||||
|
{# Autocomplete suggestions — sibling of #id_bud_panel because the panel #}
|
||||||
|
{# has overflow:hidden for its scaleX slide animation. #}
|
||||||
|
<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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var btn = document.getElementById('id_buddy_btn');
|
var btn = document.getElementById('id_bud_btn');
|
||||||
var panel = document.getElementById('id_buddy_panel');
|
var panel = document.getElementById('id_bud_panel');
|
||||||
var input = document.getElementById('id_recipient');
|
var input = document.getElementById('id_recipient');
|
||||||
var ok = document.getElementById('id_buddy_ok');
|
var ok = document.getElementById('id_bud_ok');
|
||||||
var html = document.documentElement;
|
var html = document.documentElement;
|
||||||
if (!btn || !panel || !input || !ok) return;
|
if (!btn || !panel || !input || !ok) return;
|
||||||
|
|
||||||
@@ -42,7 +55,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _open() {
|
function _open() {
|
||||||
html.classList.add('buddy-open');
|
html.classList.add('bud-open');
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
// small delay before focus so the slide-out animation can play
|
// small delay before focus so the slide-out animation can play
|
||||||
setTimeout(function () { input.focus(); }, 60);
|
setTimeout(function () { input.focus(); }, 60);
|
||||||
@@ -50,13 +63,13 @@
|
|||||||
|
|
||||||
function _close(opts) {
|
function _close(opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
html.classList.remove('buddy-open');
|
html.classList.remove('bud-open');
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
if (opts.clear !== false) input.value = '';
|
if (opts.clear !== false) input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
if (html.classList.contains('buddy-open')) {
|
if (html.classList.contains('bud-open')) {
|
||||||
_close();
|
_close();
|
||||||
} else {
|
} else {
|
||||||
_open();
|
_open();
|
||||||
@@ -65,14 +78,19 @@
|
|||||||
|
|
||||||
// Escape closes the panel, clears the field
|
// Escape closes the panel, clears the field
|
||||||
document.addEventListener('keydown', function (e) {
|
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
|
// Click-outside dismiss — same pattern as game-kit.js
|
||||||
document.addEventListener('click', function (e) {
|
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 (panel.contains(e.target)) return;
|
||||||
if (e.target === btn || btn.contains(e.target)) return;
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
// Autocomplete suggestions sit outside the panel (panel overflow
|
||||||
|
// is hidden for the scaleX slide). A click inside them must NOT
|
||||||
|
// close+clear the panel.
|
||||||
|
var sg = document.getElementById('id_bud_suggestions');
|
||||||
|
if (sg && sg.contains(e.target)) return;
|
||||||
_close();
|
_close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +125,7 @@
|
|||||||
else list.appendChild(li);
|
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:
|
// siblings under .post-header. State transitions:
|
||||||
// 0 → 1+ recipients : "just me, X" turns into
|
// 0 → 1+ recipients : "just me, X" turns into
|
||||||
// "shared between {chip}" + "& me, X"
|
// "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>
|
||||||
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" %}
|
{% extends "core/base.html" %}
|
||||||
{% load lyric_extras %}
|
{% load lyric_extras %}
|
||||||
|
|
||||||
{% block title_text %}Dashposts{% endblock title_text %}
|
{% block title_text %}Billposts{% endblock title_text %}
|
||||||
{% block header_text %}<span>Dash</span>posts{% endblock header_text %}
|
{% block header_text %}<span>Bill</span>posts{% endblock header_text %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3>{{ owner|display_name }}'s posts</h3>
|
{# Two applet-scroll sections — own posts + posts shared with me. #}
|
||||||
<ul>
|
{# Stack vertically in portrait, sit side-by-side in landscape (.--two-up).#}
|
||||||
{% for post in owner.posts.all %}
|
<div class="applet-list-page applet-list-page--two-up">
|
||||||
<li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
|
{% include "apps/applets/_partials/_applet-list-shell.html" with shell_title=owner_posts_title shell_items=owner.posts.all shell_item_template="apps/billboard/_partials/_my_posts_item.html" shell_empty="No posts yet." %}
|
||||||
{% endfor %}
|
{% include "apps/applets/_partials/_applet-list-shell.html" with shell_title=others_posts_title shell_items=owner.shared_posts.all shell_item_template="apps/billboard/_partials/_my_posts_item.html" shell_empty="Nothing shared yet." %}
|
||||||
</ul>
|
</div>
|
||||||
<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>
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends "core/base.html" %}
|
{% extends "core/base.html" %}
|
||||||
{% load lyric_extras %}
|
{% load lyric_extras %}
|
||||||
|
|
||||||
{% block title_text %}Dashpost{% endblock title_text %}
|
{% block title_text %}Billpost{% endblock title_text %}
|
||||||
{% block header_text %}<span>Dash</span>post{% endblock header_text %}
|
{% block header_text %}<span>Bill</span>post{% endblock header_text %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
<h3 class="post-title">{{ post.title }}</h3>
|
<h3 class="post-title">{{ post.title }}</h3>
|
||||||
{% with recipients=post.shared_with.all %}
|
{% with recipients=post.shared_with.all %}
|
||||||
{% if recipients %}
|
{% if recipients %}
|
||||||
<p class="post-shared-recipients">shared between {% for r in recipients %}<span class="post-recipient">{{ r|display_name }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
<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>
|
||||||
<p class="post-shared-self">& me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p>
|
<p class="post-shared-self">& me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="post-shared-self">just me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p>
|
<p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</header>
|
</header>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<ul id="id_post_table" class="post-lines">
|
<ul id="id_post_table" class="post-lines">
|
||||||
{% for line in post.lines.all %}
|
{% for line in post.lines.all %}
|
||||||
<li class="post-line {% if line.author.username == 'adman' %}post-line--system{% endif %}">
|
<li class="post-line {% if line.author.username == 'adman' %}post-line--system{% endif %}">
|
||||||
<span class="post-line-author">{{ line.author|display_name }}</span>
|
<span class="post-line-author">{{ line.author|at_handle }}</span>
|
||||||
<span class="post-line-text">{# adman-authored Lines (note unlock + share invite system prose) carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.text }}{% endif %}</span>
|
<span class="post-line-text">{# adman-authored Lines (note unlock + share invite system prose) carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.text }}{% endif %}</span>
|
||||||
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|date:'g:i A' }}</time>
|
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|date:'g:i A' }}</time>
|
||||||
</li>
|
</li>
|
||||||
@@ -69,11 +69,11 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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 #}
|
{# Suppressed on admin Posts (note unlock thread) since friend-invites #}
|
||||||
{# don't apply to system-authored threads. #}
|
{# don't apply to system-authored threads. #}
|
||||||
{% if post.kind != 'note_unlock' %}
|
{% if post.kind != 'note_unlock' %}
|
||||||
{% include "apps/billboard/_partials/_buddy_panel.html" %}
|
{% include "apps/billboard/_partials/_bud_panel.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
Reference in New Issue
Block a user