diff --git a/src/apps/billboard/static/apps/billboard/note-page.js b/src/apps/billboard/static/apps/billboard/note-page.js index c648581..0ae9cd3 100644 --- a/src/apps/billboard/static/apps/billboard/note-page.js +++ b/src/apps/billboard/static/apps/billboard/note-page.js @@ -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(); diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index fccc63c..fa2890f 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -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") diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 2300785..e16ee55 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -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//set-palette", views.note_set_palette, name="note_set_palette"), + path("note//don", views.don_title, name="don_title"), + path("note//doff", views.doff_title, name="doff_title"), path("room//scroll/", views.room_scroll, name="scroll"), path("room//scroll-position/", views.save_scroll_position, name="save_scroll_position"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 97dc18a..a44bc20 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -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": diff --git a/src/apps/dashboard/migrations/0005_alter_line_post_alter_post_owner_and_more.py b/src/apps/dashboard/migrations/0005_alter_line_post_alter_post_owner_and_more.py new file mode 100644 index 0000000..89fa5be --- /dev/null +++ b/src/apps/dashboard/migrations/0005_alter_line_post_alter_post_owner_and_more.py @@ -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), + ), + ] diff --git a/src/apps/lyric/migrations/0020_add_active_title.py b/src/apps/lyric/migrations/0020_add_active_title.py new file mode 100644 index 0000000..d9db9d8 --- /dev/null +++ b/src/apps/lyric/migrations/0020_add_active_title.py @@ -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'), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 886a6f2..7fa5951 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -44,6 +44,10 @@ class User(AbstractBaseUser): unlocked_decks = models.ManyToManyField( "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_private_key = models.TextField(blank=True, default="") diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index 2ff0853..59548e0 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -3,6 +3,7 @@ from django.contrib import auth from django.test import TestCase from django.utils import timezone +from apps.drama.models import Note 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") 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): def test_links_user_with_autogen_uid(self): login_token1 = LoginToken.objects.create(email="a@b.cde") diff --git a/src/functional_tests/test_applet_my_notes.py b/src/functional_tests/test_applet_my_notes.py index 2d429b8..2d88a93 100644 --- a/src/functional_tests/test_applet_my_notes.py +++ b/src/functional_tests/test_applet_my_notes.py @@ -398,3 +398,68 @@ class StargazerNoteFromSkyPageTest(FunctionalTest): self.browser.find_elements(By.CSS_SELECTOR, ".nw-root") )) 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, "×") diff --git a/src/static_src/scss/_note.scss b/src/static_src/scss/_note.scss index 96ffc5d..5e971ff 100644 --- a/src/static_src/scss/_note.scss +++ b/src/static_src/scss/_note.scss @@ -60,6 +60,20 @@ @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 { position: relative; display: flex; diff --git a/src/templates/apps/billboard/my_notes.html b/src/templates/apps/billboard/my_notes.html index 4e8b75f..68fae33 100644 --- a/src/templates/apps/billboard/my_notes.html +++ b/src/templates/apps/billboard/my_notes.html @@ -10,7 +10,19 @@
    {% for item in note_items %}
  • + 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 }}"> + +
    + + +

    {{ item.title }}

    diff --git a/src/templates/core/_partials/_navbar.html b/src/templates/core/_partials/_navbar.html index bff202d..99daf07 100644 --- a/src/templates/core/_partials/_navbar.html +++ b/src/templates/core/_partials/_navbar.html @@ -2,7 +2,7 @@