From 264ed5968e98062db55eeb503f3ec8fc75d8adb7 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 12 May 2026 15:52:09 -0400 Subject: [PATCH] =?UTF-8?q?bud=20panels=20DRY=20refactor:=20extract=20shar?= =?UTF-8?q?ed=20skeleton=20into=20bud-btn.js=20=E2=80=94=20the=20three=20#?= =?UTF-8?q?id=5Fbud=5Fbtn=20partials=20(=5Fbud=5Fpanel=20post-share,=20=5F?= =?UTF-8?q?bud=5Finvite=5Fpanel=20gatekeeper,=20=5Fbud=5Fadd=5Fpanel=20My?= =?UTF-8?q?=20Buds)=20duplicated=20~70%=20of=20their=20JS=20(csrf/open/clo?= =?UTF-8?q?se,=20btn=20click,=20Escape,=20click-outside=20w.=20optional=20?= =?UTF-8?q?suggestions=20excl.,=20Enter=20handler,=20OK=20POST=20+=20JSON?= =?UTF-8?q?=20routing);=20collapse=20into=20`bindBudBtn({submitUrl,=20auto?= =?UTF-8?q?completeUrl=3F,=20onSuccess(data)})`=20so=20each=20panel=20keep?= =?UTF-8?q?s=20only=20its=20markup=20+=20an=20onSuccess=20callback=20(post?= =?UTF-8?q?-share:=20=5FappendLine=20+=20=5FappendRecipientChip=20+=20Brie?= =?UTF-8?q?f.showBanner;=20gatekeeper:=20Brief.showBanner=20only;=20my-bud?= =?UTF-8?q?s:=20=5FappendBudEntry);=20autocomplete=20binding=20folded=20in?= =?UTF-8?q?to=20bud-btn.js=20(drives=20`bindBudAutocomplete`=20when=20auto?= =?UTF-8?q?completeUrl=20is=20passed)=20so=20callers=20stop=20repeating=20?= =?UTF-8?q?the=20`bindBudAutocomplete(...)`=20boilerplate;=20dead=20`data-?= =?UTF-8?q?share-url`/`data-invite-url`/`data-add-url`=20attrs=20dropped?= =?UTF-8?q?=20since=20`bindBudBtn`=20takes=20the=20URL=20directly;=20behav?= =?UTF-8?q?ior-preserving=20=E2=80=94=20all=2021=20existing=20FTs=20(test?= =?UTF-8?q?=5Fmy=5Fbuds=20+=20test=5Fbud=5Fbtn)=20+=2049=20ITs=20(test=5Fb?= =?UTF-8?q?uds=20+=20test=5Fshare=5Fpost=20+=20test=5Fbrief)=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../static/apps/billboard/bud-btn.js | 112 ++++++++++++++ .../billboard/_partials/_bud_add_panel.html | 103 +++---------- .../_partials/_bud_invite_panel.html | 117 ++------------- .../apps/billboard/_partials/_bud_panel.html | 137 +++--------------- 4 files changed, 164 insertions(+), 305 deletions(-) create mode 100644 src/apps/billboard/static/apps/billboard/bud-btn.js diff --git a/src/apps/billboard/static/apps/billboard/bud-btn.js b/src/apps/billboard/static/apps/billboard/bud-btn.js new file mode 100644 index 0000000..800af4a --- /dev/null +++ b/src/apps/billboard/static/apps/billboard/bud-btn.js @@ -0,0 +1,112 @@ +// Shared skeleton for the three #id_bud_btn slide-out panels: +// • _bud_panel.html — post-share (POSTs to billboard:share_post) +// • _bud_invite_panel.html — gatekeeper invite (POSTs to epic:invite_gamer) +// • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud) +// +// Owns: csrf cookie read, open/close + .bud-open html-class, button click, +// Escape, click-outside, Enter-in-input, OK POST + JSON routing. +// +// Each caller drives it with `bindBudBtn({submitUrl, autocompleteUrl?, onSuccess})`. +// `onSuccess(data)` does the panel-specific DOM updates (line append, chip +// append, bud-entry append, Brief banner — whatever the response carries). +// _close({clear: true}) fires automatically on a successful response. +// +// `autocompleteUrl` enables bud-autocomplete on the input (post-share + +// gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions. +// My Buds omits it — the autocomplete pool is request.user.buds, which is +// the set you can't usefully re-add. + +(function () { + 'use strict'; + + window.bindBudBtn = function (opts) { + opts = opts || {}; + 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; + + var suggestions = opts.autocompleteUrl + ? document.getElementById('id_bud_suggestions') + : null; + + 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(o) { + o = o || {}; + html.classList.remove('bud-open'); + btn.classList.remove('active'); + if (o.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. + if (suggestions && suggestions.contains(e.target)) return; + _close(); + }); + + ok.addEventListener('click', function () { + var recipient = input.value.trim(); + if (!recipient) return; + + var fd = new FormData(); + fd.set('recipient', recipient); + + fetch(opts.submitUrl, { + 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 (typeof opts.onSuccess === 'function') opts.onSuccess(data); + _close({ clear: true }); + }) + .catch(function () { + // Privacy-safe response shape — even an unregistered/self + // recipient is a 200. Network/5xx land here; just close. + }); + }); + + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + ok.click(); + } + }); + + if (opts.autocompleteUrl && window.bindBudAutocomplete && suggestions) { + window.bindBudAutocomplete(input, suggestions, { + searchUrl: opts.autocompleteUrl, + }); + } + }; +}()); diff --git a/src/templates/apps/billboard/_partials/_bud_add_panel.html b/src/templates/apps/billboard/_partials/_bud_add_panel.html index e37ca9f..fdd2e71 100644 --- a/src/templates/apps/billboard/_partials/_bud_add_panel.html +++ b/src/templates/apps/billboard/_partials/_bud_add_panel.html @@ -1,15 +1,21 @@ +{% 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. #} +{# _bud_add_panel.html — bottom-left handshake btn + slide-out add-bud #} +{# field. Skeleton (open/close/POST) owned by bud-btn.js; this partial #} +{# wires the add-bud success callback: {bud} → append .bud-entry to #} +{# #id_buds_list. #} +{# Included by my_buds.html only. #} +{# #} +{# No autocomplete — the bud-autocomplete pool is request.user.buds, #} +{# which is precisely the set you can't usefully re-add. Post-share + #} +{# gatekeeper-invite panels DO bind autocomplete. #} {# ─────────────────────────────────────────────────────────────────────── #} -
+
OK
-{# No autocomplete on this panel — the bud-autocomplete pool is the #} -{# user's existing buds, which is precisely the set you can't usefully #} -{# re-add. Post-share + gatekeeper-invite panels keep the autocomplete #} -{# binding (re-sharing with an existing bud is a real flow). #} - + diff --git a/src/templates/apps/billboard/_partials/_bud_invite_panel.html b/src/templates/apps/billboard/_partials/_bud_invite_panel.html index 2956dc3..86d0827 100644 --- a/src/templates/apps/billboard/_partials/_bud_invite_panel.html +++ b/src/templates/apps/billboard/_partials/_bud_invite_panel.html @@ -1,14 +1,10 @@ {% load static %} -{% load lyric_extras %} {# ─────────────────────────────────────────────────────────────────────── #} {# _bud_invite_panel.html — bud btn + slide-out for the gatekeeper game- #} -{# invite flow. Replaces the legacy `
` #} -{# inline panel inside _gatekeeper.html. #} -{# #} -{# Differences from the post-share variant (_bud_panel.html): #} -{# • POSTs to epic:invite_gamer instead of billboard:share_post. #} -{# • Server returns {brief, recipient_display} — no line_text (no Post #} -{# to append a Line to). JS just shows the Brief banner. #} +{# invite flow. Skeleton (open/close/POST + autocomplete) owned by #} +{# bud-btn.js; this partial wires the invite success callback: server #} +{# returns {brief, recipient_display} — no line_text (no Post to append a #} +{# Line to). JS just shows the Brief banner. #} {# #} {# Caller must pass `room` in context. #} {# ─────────────────────────────────────────────────────────────────────── #} @@ -17,9 +13,7 @@ -
+
+ - - diff --git a/src/templates/apps/billboard/_partials/_bud_panel.html b/src/templates/apps/billboard/_partials/_bud_panel.html index 2b0249e..833f5d1 100644 --- a/src/templates/apps/billboard/_partials/_bud_panel.html +++ b/src/templates/apps/billboard/_partials/_bud_panel.html @@ -2,12 +2,13 @@ {% load lyric_extras %} {# ─────────────────────────────────────────────────────────────────────── #} {# _bud_panel.html — bottom-left handshake btn + slide-out recipient #} -{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #} -{# right). Included by post.html only. #} +{# field for the share-post async flow. Skeleton (open/close/POST + auto- #} +{# complete) owned by bud-btn.js; this partial wires the share-post #} +{# success callback: appends a Line to #id_post_table, fires Brief.show- #} +{# Banner, and updates the .post-header shared-with prose. #} +{# Included by post.html only. #} {# #} -{# Spec lives in functional_tests/test_bud_btn.py — write it red-first. #} -{# Run: #} -{# python src/manage.py test functional_tests.test_bud_btn #} +{# Spec lives in functional_tests/test_bud_btn.py. #} {# ─────────────────────────────────────────────────────────────────────── #}
- - +