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 ────────────────────────────────────────────────────────────────── // ── 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() { function _init() {
document.querySelectorAll('.note-item__image-box').forEach(function (box) { document.querySelectorAll('.note-item__image-box').forEach(function (box) {
box.addEventListener('click', function (e) { 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 // Body click → dismiss modal and revert any preview
document.body.addEventListener('click', function () { document.body.addEventListener('click', function () {
if (_selectedPalette) _revertPreview(); if (_selectedPalette) _revertPreview();

View File

@@ -282,6 +282,51 @@ class NoteSetPaletteViewTest(TestCase):
self.assertEqual(self.user.palette, "palette-bardo") 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): class SaveScrollPositionTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="test@savescroll.io") 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("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("my-notes/", views.my_notes, name="my_notes"), 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>/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/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"), 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="/") @login_required(login_url="/")
def my_notes(request): def my_notes(request):
qs = Note.objects.filter(user=request.user) qs = Note.objects.filter(user=request.user)
active_title = request.user.active_title
note_items = [ note_items = [
{ {
"obj": n, "obj": n,
@@ -144,6 +145,7 @@ def my_notes(request):
"description": _NOTE_META.get(n.slug, {}).get("description", ""), "description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []), "palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "", "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 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="/") @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":

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2026-04-23 05:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0004_rename_note_to_post'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='line',
name='post',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='dashboard.post'),
),
migrations.AlterField(
model_name='post',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='post',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0 on 2026-04-23 05:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0005_rename_recognition_to_note'),
('lyric', '0019_sky_birth_tz'),
]
operations = [
migrations.AddField(
model_name='user',
name='active_title',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='drama.note'),
),
]

View File

@@ -44,6 +44,10 @@ 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",
) )
active_title = models.ForeignKey(
"drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
ap_public_key = models.TextField(blank=True, default="") ap_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="") ap_private_key = models.TextField(blank=True, default="")

View File

@@ -3,6 +3,7 @@ from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from apps.drama.models import Note
from apps.lyric.models import LoginToken, PaymentMethod, Token, User, Wallet from apps.lyric.models import LoginToken, PaymentMethod, Token, User, Wallet
@@ -29,6 +30,27 @@ class UserModelTest(TestCase):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.assertFalse(user.searchable) self.assertFalse(user.searchable)
def test_active_title_is_null_by_default(self):
user = User.objects.create(email="a@b.cde")
self.assertIsNone(user.active_title)
def test_active_title_can_be_set_to_a_note(self):
user = User.objects.create(email="a@b.cde")
note = Note.objects.create(user=user, slug="stargazer", earned_at=timezone.now())
user.active_title = note
user.save(update_fields=["active_title"])
user.refresh_from_db()
self.assertEqual(user.active_title, note)
def test_clearing_note_nullifies_active_title(self):
user = User.objects.create(email="a@b.cde")
note = Note.objects.create(user=user, slug="stargazer", earned_at=timezone.now())
user.active_title = note
user.save(update_fields=["active_title"])
note.delete()
user.refresh_from_db()
self.assertIsNone(user.active_title)
class LoginTokenModelTest(TestCase): class LoginTokenModelTest(TestCase):
def test_links_user_with_autogen_uid(self): def test_links_user_with_autogen_uid(self):
login_token1 = LoginToken.objects.create(email="a@b.cde") login_token1 = LoginToken.objects.create(email="a@b.cde")

View File

@@ -398,3 +398,68 @@ class StargazerNoteFromSkyPageTest(FunctionalTest):
self.browser.find_elements(By.CSS_SELECTOR, ".nw-root") self.browser.find_elements(By.CSS_SELECTOR, ".nw-root")
)) ))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".note-banner")) self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"))
# ──────────────────────────────────────────────────────────────────────────────
# Title equip — DON/DOFF buttons on the note item
# ──────────────────────────────────────────────────────────────────────────────
class NoteEquipTitleTest(FunctionalTest):
"""DON button equips the note title as the sitewide greeting; DOFF restores it."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="billboard-notes",
defaults={"name": "My Notes", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
def test_don_equips_title_greeting_and_doff_restores(self):
"""DON replaces 'Earthman' with the note title; DOFF restores 'Earthman'.
DON/DOFF follow the game-kit equip button pattern: the active btn is normal,
the inactive btn is × + btn-disabled."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url + "/billboard/my-notes/")
note_item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item")
)
don_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-equip")
doff_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-unequip")
# Initial state: DON active ("DON"), DOFF disabled ("×")
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "DON")
self.assertIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "×")
# Click DON → greeting changes to the note title
don_btn.click()
self.wait_for(
lambda: self.assertIn(
"Stargazer",
self.browser.find_element(By.ID, "id_greeting_name").text,
)
)
self.assertIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "×")
self.assertNotIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "DOFF")
# Click DOFF → greeting restored to "Earthman"
doff_btn.click()
self.wait_for(
lambda: self.assertIn(
"Earthman",
self.browser.find_element(By.ID, "id_greeting_name").text,
)
)
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "DON")
self.assertIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "×")

View File

@@ -60,6 +60,20 @@
@media (min-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (min-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
} }
// DON/DOFF equip buttons — positioned outside the top-left corner,
// matching the .tt-equip-btns pattern from the game kit tooltip.
.note-don-doff {
position: absolute;
left: -1rem;
top: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
z-index: 1;
.btn { margin: 0; }
}
.note-item { .note-item {
position: relative; position: relative;
display: flex; display: flex;

View File

@@ -10,7 +10,19 @@
<ul class="note-list"> <ul class="note-list">
{% for item in note_items %} {% for item in note_items %}
<li class="note-item" data-slug="{{ item.obj.slug }}" <li class="note-item" data-slug="{{ item.obj.slug }}"
data-set-palette-url="{% url 'billboard:note_set_palette' item.obj.slug %}"> data-set-palette-url="{% url 'billboard:note_set_palette' item.obj.slug %}"
data-don-url="{% url 'billboard:don_title' item.obj.slug %}"
data-doff-url="{% url 'billboard:doff_title' item.obj.slug %}"
data-title="{{ item.title }}">
<div class="note-don-doff">
<button type="button"
class="btn btn-equip note-don-btn{% if item.is_equipped %} btn-disabled{% endif %}"
>{% if item.is_equipped %}&times;{% else %}DON{% endif %}</button>
<button type="button"
class="btn btn-unequip note-doff-btn{% if not item.is_equipped %} btn-disabled{% endif %}"
>{% if not item.is_equipped %}&times;{% else %}DOFF{% endif %}</button>
</div>
<div class="note-item__body"> <div class="note-item__body">
<p class="note-item__title">{{ item.title }}</p> <p class="note-item__title">{{ item.title }}</p>

View File

@@ -2,7 +2,7 @@
<nav class="navbar"> <nav class="navbar">
<div class="container-fluid"> <div class="container-fluid">
<a href="/" class="navbar-brand"> <a href="/" class="navbar-brand">
<h1>Welcome,<br>Earthman</h1> <h1>Welcome,<br><span id="id_greeting_name">{% if user.active_title %}{{ user.active_title.slug|capfirst }}{% else %}Earthman{% endif %}</span></h1>
</a> </a>
{% if user.email %} {% if user.email %}
<div class="navbar-user"> <div class="navbar-user">