buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
- applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
- billboard.views.my_buddies + add_buddy:
• my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
• add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
- templates:
• _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
• my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
• _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
- SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
- tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
- 841 ITs (+14) + 4 my_buddies FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<section
|
||||
id="id_applet_my_contacts"
|
||||
id="id_applet_my_buddies"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2>Contacts</h2>
|
||||
<h2><a href="{% url 'billboard:my_buddies' %}" class="my-buddies-main">My Buddies</a></h2>
|
||||
{% include "core/_partials/_forthcoming.html" %}
|
||||
</section>
|
||||
126
src/templates/apps/billboard/_partials/_buddy_add_panel.html
Normal file
126
src/templates/apps/billboard/_partials/_buddy_add_panel.html
Normal file
@@ -0,0 +1,126 @@
|
||||
{% load static %}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
{# _buddy_add_panel.html — bottom-left handshake btn + slide-out add- #}
|
||||
{# buddy field. Mirrors _buddy_panel.html (post-share) but POSTs to #}
|
||||
{# add_buddy and appends to #id_buddies_list instead of #id_post_table. #}
|
||||
{# Included by my_buddies.html only. #}
|
||||
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||
|
||||
<button id="id_buddy_btn" type="button" aria-label="Add a buddy">
|
||||
<i class="fa-solid fa-handshake"></i>
|
||||
</button>
|
||||
|
||||
<div id="id_buddy_panel" data-add-url="{% url 'billboard:add_buddy' %}">
|
||||
<input id="id_recipient"
|
||||
name="recipient"
|
||||
type="email"
|
||||
placeholder="friend@example.com"
|
||||
autocomplete="off">
|
||||
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var btn = document.getElementById('id_buddy_btn');
|
||||
var panel = document.getElementById('id_buddy_panel');
|
||||
var input = document.getElementById('id_recipient');
|
||||
var ok = document.getElementById('id_buddy_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('buddy-open');
|
||||
btn.classList.add('active');
|
||||
setTimeout(function () { input.focus(); }, 60);
|
||||
}
|
||||
|
||||
function _close(opts) {
|
||||
opts = opts || {};
|
||||
html.classList.remove('buddy-open');
|
||||
btn.classList.remove('active');
|
||||
if (opts.clear !== false) input.value = '';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (html.classList.contains('buddy-open')) {
|
||||
_close();
|
||||
} else {
|
||||
_open();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!html.classList.contains('buddy-open')) return;
|
||||
if (panel.contains(e.target)) return;
|
||||
if (e.target === btn || btn.contains(e.target)) return;
|
||||
_close();
|
||||
});
|
||||
|
||||
function _appendBuddyEntry(buddy) {
|
||||
var list = document.getElementById('id_buddies_list');
|
||||
if (!list || !buddy) return;
|
||||
// Skip if already in DOM (server-side dedup ensures M2M idempotence;
|
||||
// this guards a fast double-click that races the post-add refresh).
|
||||
if (list.querySelector('[data-buddy-id="' + buddy.id + '"]')) return;
|
||||
// Drop the empty-state row if present
|
||||
var empty = list.querySelector('.buddy-entry--empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
var li = document.createElement('li');
|
||||
li.className = 'buddy-entry';
|
||||
li.dataset.buddyId = buddy.id;
|
||||
var name = document.createElement('span');
|
||||
name.className = 'buddy-name';
|
||||
name.textContent = buddy.username;
|
||||
li.appendChild(name);
|
||||
var buffer = list.querySelector('.buddy-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.buddy) _appendBuddyEntry(data.buddy);
|
||||
_close({ clear: true });
|
||||
})
|
||||
.catch(function () {
|
||||
// Privacy-safe response shape — even an unregistered email is
|
||||
// a 200 w. {buddy: null}. Network/5xx land here; just close.
|
||||
});
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
ok.click();
|
||||
}
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
Reference in New Issue
Block a user