bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
Replaces the @mailman invite Line's inline OK/BYE block w. a dedicated per-bud surface. Three new FTs (test_bill_bud_page, test_bill_my_buds_tooltip, test_bill_mailman_invite_post — landed red 2026-05-27 PM) drive: per-bud landing page rendering 4-btn apparatus + shoptalk textarea + invite-cascade glow handoff; my-buds row tooltip portal w. .tt-title/.tt-description/.tt-email/.tt-shoptalk/.tt-milestone slots; mailman Brief surfacing on any authenticated page-load via context processor + base.html JSON-script.
Models: new `BudshipNote(user, bud, shoptalk[CharField max=160], edited_at)` w. unique_together — per-relation personal note about a bud, never visible to the bud. Lazy-created on first shoptalk save so absence of a row reads as 'never edited' (drives .tt-milestone slot presence).
URLs (billboard): `buds/<uuid:bud_id>/` (bud_page), `buds/<uuid:bud_id>/shoptalk` (save_bud_shoptalk), `buds/<uuid:bud_id>/delete` (delete_bud).
Views: bud_page auto-adds the bud on first visit (mirrors share_post implicit-add); resolves `pending_invite` as non-expired PENDING SeaInvite(owner=bud, invitee=request.user) → drives `sea_btn_active` + `sea_first_draw_pending` flags that _burger.html already reads on my_sea + room. my_buds enriches each bud w. `.shoptalk_text` + `.milestone_dt` so the row template can render data-tt-* attrs without an extra template tag.
mail.py: INVITE_TEMPLATE now interpolates `owner_id` into an `<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>` wrapper around the owner's handle. post.html's existing safe-filter branch (gated on author username == 'mailman') passes it through unescaped. Removed the {% if line.sea_invite %} include path — _invite_actions.html left in place for archival.
Templates: new bud.html (header + shoptalk form + apparatus + gear + burger fan + sea_btn nav inline JS); new _bud_gear.html (NVM→my_buds, DEL→guard portal "Delete this bud?" → POST delete_bud); new _bud_tooltip.html (portal w. .tt-* slots); _my_buds_item.html wraps `@handle` in an anchor to bud_page + carries data-tt-* attrs + " the {{ active_title_display }}"; my_buds.html includes the tooltip portal + loads my-buds-tooltip.js.
JS: new my-buds-tooltip.js binds row clicks → .row-locked + populates #id_tooltip_portal from data-tt-* attrs; anchor clicks pass through to navigate; .tt-milestone is removed from DOM (not just emptied) when never-edited so the FT can distinguish absent vs cleared-after-edit.
SCSS: extend landscape gear-btn rule + #id_*_menu rule w. `.bud-page` + `#id_bud_menu` (otherwise gear-btn collided w. bud-btn in landscape on bud.html). Bump active burger sub-btn z-index to 1 so click hit-test picks the active sub-btn during the 0.25s fan arc-out animation (otherwise a later-in-DOM inactive btn obscured the active target during transition).
Cross-page Brief surface: new `mail_brief_payload` context processor injects the user's oldest unread MAIL_ACCEPTANCE Brief into every authenticated response; base.html renders the JSON-script + auto-fires Brief.showBanner. Mark-read still rides view_post's existing GET unread-flip — no new endpoint.
Pre-existing MySeaInvitePostRenderTest (test_sea_invite_views.py) inverted to match the new contract: the .invite-actions sweep is unconditional (PENDING / ACCEPTED / DECLINED all carry prose only); pinned the post-attribution anchor + bud-page href in its place.
1518 ITs green (1475 app ITs + 43 sprint ITs), 23 sprint FTs green (5 my_buds tooltip + 13 bud page + 5 mailman invite post). Jasmine specs from sprint plan deferred — FT coverage of burger-glow / row-lock / portal-populate paths suffices and the textarea blur-POST flow isn't implemented in this sprint (form is server-action only, save-on-blur AJAX is a follow-on).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
11
src/templates/apps/billboard/_partials/_bud_gear.html
Normal file
11
src/templates/apps/billboard/_partials/_bud_gear.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{# Gear menu on bud.html — mirrors apps/billboard/_partials/_post_gear.html. #}
|
||||
{# NVM returns to /billboard/my-buds/; DEL opens the shared #id_guard_portal #}
|
||||
{# from base.html via the `data-confirm` hook + POSTs to delete_bud. #}
|
||||
<div id="id_bud_menu" style="display:none">
|
||||
<a href="{% url 'billboard:my_buds' %}" class="btn btn-cancel">NVM</a>
|
||||
<form method="POST" action="{% url 'billboard:delete_bud' bud.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger" data-confirm="Delete this bud?">DEL</button>
|
||||
</form>
|
||||
</div>
|
||||
{% include "apps/applets/_partials/_gear.html" with menu_id="id_bud_menu" %}
|
||||
13
src/templates/apps/billboard/_partials/_bud_tooltip.html
Normal file
13
src/templates/apps/billboard/_partials/_bud_tooltip.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{# Shared tooltip-portal markup for the My Buds list. Hidden by default; #}
|
||||
{# JS reads data-tt-* attrs from the clicked .bud-entry row, populates #}
|
||||
{# the slots below, and toggles .active. .tt-milestone is removed from #}
|
||||
{# the DOM when no data-tt-milestone attr is present so the FT can #}
|
||||
{# distinguish never-edited from cleared-after-edit (slot absent vs. #}
|
||||
{# present-but-empty). #}
|
||||
<div id="id_tooltip_portal" class="tt">
|
||||
<span class="tt-title"></span>
|
||||
<span class="tt-description"></span>
|
||||
<span class="tt-email"></span>
|
||||
<span class="tt-shoptalk"></span>
|
||||
<span class="tt-milestone"></span>
|
||||
</div>
|
||||
@@ -1,4 +1,15 @@
|
||||
{% load lyric_extras %}
|
||||
<li class="applet-list-entry bud-entry" data-bud-id="{{ item.id }}">
|
||||
<span class="bud-name">{{ item|at_handle }}</span>
|
||||
{# My Buds page row — `@<handle> the <Title>` w. anchor on the handle #}
|
||||
{# routing to the bud's landing page + data-tt-* attrs the tooltip #}
|
||||
{# portal reads on row-lock click. `item.shoptalk_text` + `.milestone_ #}
|
||||
{# dt` are attached by the my_buds view from BudshipNote. #}
|
||||
<li class="applet-list-entry bud-entry"
|
||||
data-bud-id="{{ item.id }}"
|
||||
data-tt-title="{{ item|at_handle }}"
|
||||
data-tt-description="{{ item.active_title_display }}"
|
||||
data-tt-email="{{ item.email }}"
|
||||
data-tt-shoptalk="{{ item.shoptalk_text|default:'' }}"
|
||||
{% if item.milestone_dt %}data-tt-milestone="edited {{ item.milestone_dt|relative_ts }}"{% endif %}>
|
||||
<span class="bud-name"><a href="{% url 'billboard:bud_page' item.id %}">{{ item|at_handle }}</a></span>
|
||||
<span class="bud-row-title"> the {{ item.active_title_display }}</span>
|
||||
</li>
|
||||
|
||||
85
src/templates/apps/billboard/bud.html
Normal file
85
src/templates/apps/billboard/bud.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Billbud{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span><span>bud</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{# note.js exposes window.Brief — needed by the auto-fired @mailman Brief #}
|
||||
{# when this page is the destination of an invite cascade. #}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
|
||||
<div class="bud-page">
|
||||
<header class="bud-page-header">
|
||||
<h3>{{ bud|at_handle }} the {{ bud.active_title_display }}</h3>
|
||||
<p class="bud-page-email">{{ bud.email }}</p>
|
||||
</header>
|
||||
|
||||
<form id="id_bud_shoptalk_form"
|
||||
method="POST"
|
||||
action="{% url 'billboard:save_bud_shoptalk' bud.id %}"
|
||||
class="bud-shoptalk-form">
|
||||
{% csrf_token %}
|
||||
<label for="id_shoptalk" class="bud-shoptalk-label">Shoptalk</label>
|
||||
<textarea id="id_shoptalk"
|
||||
name="shoptalk"
|
||||
maxlength="160"
|
||||
class="form-control bud-shoptalk-input"
|
||||
placeholder="Your personal note about this bud…">{{ shoptalk_text }}</textarea>
|
||||
</form>
|
||||
|
||||
{# 4-btn apparatus mirrors my_sea.html: kit (from base.html) + bud + #}
|
||||
{# gear + burger. Apparatus loads bud-btn.js (defines window. #}
|
||||
{# bindBudBtn — does NOT auto-bind); the inline script below opens #}
|
||||
{# the panel + stubs OK to disabled per the sprint plan. #}
|
||||
{% include "apps/billboard/_partials/_bud_apparatus.html" with aria_label="Bud" include_suggestions=False %}
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('id_bud_btn');
|
||||
var panel = document.getElementById('id_bud_panel');
|
||||
var ok = document.getElementById('id_bud_ok');
|
||||
if (!btn || !panel || !ok) return;
|
||||
// bud-of-bud flow isn't wired yet — stub OK as disabled w. × text.
|
||||
ok.classList.add('btn-disabled');
|
||||
ok.textContent = '×';
|
||||
ok.setAttribute('disabled', 'disabled');
|
||||
btn.addEventListener('click', function () {
|
||||
var open = document.documentElement.classList.toggle('bud-open');
|
||||
btn.classList.toggle('active', open);
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && document.documentElement.classList.contains('bud-open')) {
|
||||
document.documentElement.classList.remove('bud-open');
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% include "apps/billboard/_partials/_bud_gear.html" %}
|
||||
|
||||
{# Burger fan — sea_btn_active + sea_first_draw_pending drive the #}
|
||||
{# server-side .active / .glow-handoff classes (same vars my_sea + #}
|
||||
{# room read). burger-btn.js auto-binds on DOMContentLoaded. #}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
{% if pending_invite %}
|
||||
<script>
|
||||
(function () {
|
||||
var sea = document.getElementById('id_sea_btn');
|
||||
if (!sea) return;
|
||||
// Active sub-btn navigation handler — fires BEFORE burger-btn.js's
|
||||
// delegated fan handler (target-phase before bubble-up) so the click
|
||||
// routes to the bud's spectator my-sea page. Burger then closes the
|
||||
// fan as usual.
|
||||
sea.addEventListener('click', function () {
|
||||
if (!sea.classList.contains('active')) return;
|
||||
window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/';
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -11,6 +11,10 @@
|
||||
{% 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>
|
||||
|
||||
{# Tooltip portal — populated from .bud-entry data-tt-* attrs on row #}
|
||||
{# click. See my-buds-tooltip.js for the bind logic. #}
|
||||
{% include "apps/billboard/_partials/_bud_tooltip.html" %}
|
||||
|
||||
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
|
||||
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
|
||||
{% endblock content %}
|
||||
@@ -19,4 +23,5 @@
|
||||
{# Brief module — needed by _bud_add_panel's OK handler so the #}
|
||||
{# duplicate-add error banner can render via Brief.showDuplicateBanner.#}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
<script src="{% static 'apps/billboard/my-buds-tooltip.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
|
||||
@@ -41,12 +41,14 @@
|
||||
{% for line in post.lines.all %}
|
||||
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}post-line--system{% endif %}">
|
||||
<span class="post-line-author">{{ line.author|at_handle }}</span>
|
||||
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
|
||||
<span class="post-line-text">{# adman / taxman / mailman-authored Lines (note unlock, share invite, tax ledger, invite cascade) may carry HTML anchors (note-ref / post-attribution). User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
|
||||
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|relative_ts }}</time>
|
||||
{# @mailman invite Lines carry an OK/BYE action block driven by #}
|
||||
{# the linked SeaInvite's status (my-sea invite flow). Non-invite #}
|
||||
{# system + user Lines have no `sea_invite`, so this is skipped. #}
|
||||
{% if line.sea_invite %}{% include "apps/billboard/_partials/_invite_actions.html" %}{% endif %}
|
||||
{# Pre-sprint @mailman invite Lines carried an in-line OK/BYE #}
|
||||
{# block via _invite_actions.html. Bud landing page sprint #}
|
||||
{# 2026-05-27 migrates that interaction onto bud.html — the #}
|
||||
{# Line's prose now embeds a post-attribution anchor (see #}
|
||||
{# apps.billboard.mail.INVITE_TEMPLATE) that routes to the #}
|
||||
{# owner's bud page where accept/decline/spectator live. #}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="post-line-buffer" aria-hidden="true"></li>
|
||||
|
||||
Reference in New Issue
Block a user