note title equip: DON/DOFF buttons outside top-left corner; User.active_title FK; don/doff views; greeting updated via JS — TDD

- lyric/models.py: active_title = ForeignKey('drama.Note', null=True, SET_NULL)
- lyric migration 0020_add_active_title
- billboard/views.py: don_title + doff_title views; is_equipped per note_item context
- billboard/urls.py: note/<slug>/don + note/<slug>/doff routes
- _navbar.html: id_greeting_name span; shows active_title.slug|capfirst when set
- my_notes.html: .note-don-doff buttons (DON/DOFF, × when disabled); data-don-url/doff-url/title attrs
- note-page.js: _bindDonDoff() — DON POST sets greeting + swaps btn state; DOFF restores Earthman
- _note.scss: .note-don-doff position:absolute left:-1rem top:0; flex-direction:column gap:1.25rem
- ITs: NoteEquipTitleViewTest (5 tests); UserModelTest.test_active_title_* (3 tests)
- FT: NoteEquipTitleTest.test_don_equips_title_greeting_and_doff_restores

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-23 01:44:58 -04:00
parent 7d4389a74a
commit 214120ef2d
12 changed files with 289 additions and 2 deletions

View File

@@ -173,6 +173,50 @@
// ── init ──────────────────────────────────────────────────────────────────
function _setGreeting(name) {
var el = document.getElementById('id_greeting_name');
if (el) el.textContent = name;
}
function _bindDonDoff(item) {
var donBtn = item.querySelector('.note-don-btn');
var doffBtn = item.querySelector('.note-doff-btn');
if (!donBtn || !doffBtn) return;
donBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (donBtn.classList.contains('btn-disabled')) return;
fetch(item.dataset.donUrl, {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRFToken': _getCsrf() },
})
.then(function (r) { return r.json(); })
.then(function (data) {
_setGreeting(data.title);
donBtn.classList.add('btn-disabled');
donBtn.textContent = '×';
doffBtn.classList.remove('btn-disabled');
doffBtn.textContent = 'DOFF';
});
});
doffBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (doffBtn.classList.contains('btn-disabled')) return;
fetch(item.dataset.doffUrl, {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRFToken': _getCsrf() },
})
.then(function () {
_setGreeting('Earthman');
doffBtn.classList.add('btn-disabled');
doffBtn.textContent = '×';
donBtn.classList.remove('btn-disabled');
donBtn.textContent = 'DON';
});
});
}
function _init() {
document.querySelectorAll('.note-item__image-box').forEach(function (box) {
box.addEventListener('click', function (e) {
@@ -182,6 +226,10 @@
});
});
document.querySelectorAll('.note-item').forEach(function (item) {
_bindDonDoff(item);
});
// Body click → dismiss modal and revert any preview
document.body.addEventListener('click', function () {
if (_selectedPalette) _revertPreview();

View File

@@ -282,6 +282,51 @@ class NoteSetPaletteViewTest(TestCase):
self.assertEqual(self.user.palette, "palette-bardo")
class NoteEquipTitleViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="don@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
def test_don_sets_active_title(self):
self.client.post("/billboard/note/stargazer/don")
self.user.refresh_from_db()
self.assertEqual(self.user.active_title, self.note)
def test_doff_clears_active_title(self):
self.user.active_title = self.note
self.user.save(update_fields=["active_title"])
self.client.post("/billboard/note/stargazer/doff")
self.user.refresh_from_db()
self.assertIsNone(self.user.active_title)
def test_don_returns_200_with_title(self):
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["title"], "Stargazer")
def test_doff_returns_200(self):
response = self.client.post("/billboard/note/stargazer/doff")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
def test_don_requires_login(self):
self.client.logout()
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 302)
def test_don_returns_404_for_unowned_note(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.client.logout()
self.client.force_login(other)
response = self.client.post("/billboard/note/stargazer/don")
# other user's own note — should work
self.assertEqual(response.status_code, 200)
class SaveScrollPositionTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@savescroll.io")

View File

@@ -9,6 +9,8 @@ urlpatterns = [
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("my-notes/", views.my_notes, name="my_notes"),
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
path("note/<slug:slug>/don", views.don_title, name="don_title"),
path("note/<slug:slug>/doff", views.doff_title, name="doff_title"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
]

View File

@@ -137,6 +137,7 @@ def note_set_palette(request, slug):
@login_required(login_url="/")
def my_notes(request):
qs = Note.objects.filter(user=request.user)
active_title = request.user.active_title
note_items = [
{
"obj": n,
@@ -144,6 +145,7 @@ def my_notes(request):
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
"is_equipped": active_title is not None and active_title.pk == n.pk,
}
for n in qs
]
@@ -154,6 +156,28 @@ def my_notes(request):
})
@login_required(login_url="/")
def don_title(request, slug):
from django.http import Http404
try:
note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist:
raise Http404
if request.method == "POST":
request.user.active_title = note
request.user.save(update_fields=["active_title"])
title = _NOTE_META.get(slug, {}).get("title", slug.capitalize())
return JsonResponse({"title": title})
@login_required(login_url="/")
def doff_title(request, slug):
if request.method == "POST":
request.user.active_title = None
request.user.save(update_fields=["active_title"])
return JsonResponse({"ok": True})
@login_required(login_url="/")
def save_scroll_position(request, room_id):
if request.method != "POST":