recognition: page, palette modal, & dashboard palette unlock — TDD
- billboard/recognition/ view + template; recognition/<slug>/set-palette endpoint (no trailing slash) - recognition.html: <template>-based modal (clone on open, remove on close — Selenium find_elements compatible) - recognition-page.js: image-box → modal → swatch preview → body-click restore → OK → confirm → POST set-palette - _palettes_for_user() replaces static PALETTES; Recognition.palette unlocks swatch + populates data-shoptalk - _unlocked_palettes_for_user() wires dynamic unlock check into set_palette view - _applet-palette.html: data-shoptalk from context instead of hard-coded "Placeholder" - _recognition.scss: banner, recog-list/item, image-box, modal, palette-confirm; :not([hidden]) pattern avoids display override - FT T2 split into T2a (banner → FYI → recog page), T2b (palette modal flow), T2c (dashboard palette applet) - 684 ITs green; 7 FTs green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
src/apps/billboard/static/apps/billboard/recognition-page.js
Normal file
143
src/apps/billboard/static/apps/billboard/recognition-page.js
Normal file
@@ -0,0 +1,143 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _state = 'closed'; // 'closed' | 'open' | 'previewing'
|
||||
var _selectedPalette = null;
|
||||
var _activeItem = null;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _activeModal() {
|
||||
return _activeItem && _activeItem.querySelector('.recog-palette-modal');
|
||||
}
|
||||
|
||||
function _paletteClass(el) {
|
||||
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
|
||||
}
|
||||
|
||||
function _getCsrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
// ── modal lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
function _openModal() {
|
||||
// Clone from <template> if the modal is not in the DOM yet.
|
||||
var existing = _activeModal();
|
||||
if (!existing) {
|
||||
var tpl = _activeItem.querySelector('.recog-palette-modal-tpl');
|
||||
if (!tpl) return;
|
||||
var clone = tpl.content.firstElementChild.cloneNode(true);
|
||||
_activeItem.appendChild(clone);
|
||||
_wireModal();
|
||||
}
|
||||
_state = 'open';
|
||||
var confirmEl = _activeModal().querySelector('.recog-palette-confirm');
|
||||
if (confirmEl) confirmEl.hidden = true;
|
||||
}
|
||||
|
||||
function _closeModal() {
|
||||
_state = 'closed';
|
||||
var modal = _activeModal();
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
// Wire event listeners onto the freshly-cloned modal DOM.
|
||||
function _wireModal() {
|
||||
var modal = _activeModal();
|
||||
if (!modal) return;
|
||||
|
||||
// Swatch body → preview (remove modal from DOM)
|
||||
modal.querySelectorAll('.recog-swatch-body').forEach(function (body) {
|
||||
body.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_selectedPalette = _paletteClass(body.parentElement);
|
||||
_state = 'previewing';
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// OK button (not inside confirm submenu) → show confirm submenu
|
||||
modal.querySelectorAll('.btn.btn-confirm').forEach(function (btn) {
|
||||
if (btn.closest('.recog-palette-confirm')) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_selectedPalette = _paletteClass(btn.parentElement);
|
||||
var confirmEl = modal.querySelector('.recog-palette-confirm');
|
||||
if (confirmEl) confirmEl.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm YES → POST and update DOM
|
||||
modal.querySelectorAll('.recog-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_doSetPalette();
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm NVM → hide confirm submenu
|
||||
modal.querySelectorAll('.recog-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
btn.closest('.recog-palette-confirm').hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Stop all other modal clicks from reaching body handler.
|
||||
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
|
||||
// ── set-palette POST ──────────────────────────────────────────────────────
|
||||
|
||||
function _doSetPalette() {
|
||||
var url = _activeItem.dataset.setPaletteUrl;
|
||||
var palette = _selectedPalette;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': _getCsrf(),
|
||||
},
|
||||
body: JSON.stringify({ palette: palette }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
_closeModal();
|
||||
var imageBox = _activeItem.querySelector('.recog-item__image-box');
|
||||
if (imageBox) {
|
||||
var swatch = document.createElement('div');
|
||||
swatch.className = 'recog-item__palette ' + palette;
|
||||
imageBox.parentNode.replaceChild(swatch, imageBox);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _init() {
|
||||
// Image-box click → open modal
|
||||
document.querySelectorAll('.recog-item__image-box').forEach(function (box) {
|
||||
box.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_activeItem = box.closest('.recog-item');
|
||||
_openModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Body click → restore modal when previewing
|
||||
document.body.addEventListener('click', function () {
|
||||
if (_state === 'previewing') {
|
||||
_openModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
}());
|
||||
@@ -1,8 +1,11 @@
|
||||
import json as _json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
@@ -161,6 +164,102 @@ class BillscrollViewTest(TestCase):
|
||||
self.assertContains(response, 'class="drama-event-time"')
|
||||
|
||||
|
||||
class RecognitionPageViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="recog@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_returns_200(self):
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_uses_recognition_template(self):
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/recognition.html")
|
||||
|
||||
def test_passes_recognitions_in_context(self):
|
||||
recog = Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertIn(recog, response.context["recognitions"])
|
||||
|
||||
def test_excludes_other_users_recognitions(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
Recognition.objects.create(
|
||||
user=other, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(list(response.context["recognitions"]), [])
|
||||
|
||||
def test_renders_recog_list_and_items(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertContains(response, 'class="recog-list"')
|
||||
self.assertContains(response, 'class="recog-item"')
|
||||
|
||||
def test_renders_recog_item_title_description_image_box(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertContains(response, 'class="recog-item__title"')
|
||||
self.assertContains(response, 'class="recog-item__description"')
|
||||
self.assertContains(response, 'class="recog-item__image-box"')
|
||||
|
||||
|
||||
class RecognitionSetPaletteViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="setpal@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.recognition = Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
self.url = "/billboard/recognition/stargazer/set-palette"
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_sets_palette_on_recognition(self):
|
||||
self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.recognition.refresh_from_db()
|
||||
self.assertEqual(self.recognition.palette, "palette-bardo")
|
||||
|
||||
def test_returns_200_with_ok(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"ok": True})
|
||||
|
||||
def test_returns_404_for_slug_user_does_not_own(self):
|
||||
response = self.client.post(
|
||||
"/billboard/recognition/schizo/set-palette",
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class SaveScrollPositionTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@savescroll.io")
|
||||
|
||||
@@ -7,6 +7,8 @@ app_name = "billboard"
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||
path("recognition/", views.recognition_page, name="recognition"),
|
||||
path("recognition/<slug:slug>/set-palette", views.recognition_set_palette, name="recognition_set_palette"),
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Max, Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.drama.models import GameEvent, ScrollPosition
|
||||
from apps.drama.models import GameEvent, Recognition, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
from apps.epic.utils import rooms_for_user
|
||||
|
||||
@@ -68,6 +71,58 @@ def room_scroll(request, room_id):
|
||||
})
|
||||
|
||||
|
||||
_RECOGNITION_META = {
|
||||
"stargazer": {
|
||||
"title": "Stargazer",
|
||||
"description": "You saved your first personal sky chart.",
|
||||
"palette_options": ["palette-bardo", "palette-sheol"],
|
||||
},
|
||||
"schizo": {
|
||||
"title": "Schizo",
|
||||
"description": "The socius recognizes the line of flight.",
|
||||
"palette_options": [],
|
||||
},
|
||||
"nomad": {
|
||||
"title": "Nomad",
|
||||
"description": "The socius recognizes the smooth space.",
|
||||
"palette_options": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def recognition_set_palette(request, slug):
|
||||
from django.http import Http404
|
||||
try:
|
||||
recognition = Recognition.objects.get(user=request.user, slug=slug)
|
||||
except Recognition.DoesNotExist:
|
||||
raise Http404
|
||||
if request.method == "POST":
|
||||
body = json.loads(request.body)
|
||||
recognition.palette = body.get("palette", "")
|
||||
recognition.save(update_fields=["palette"])
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def recognition_page(request):
|
||||
qs = Recognition.objects.filter(user=request.user)
|
||||
recognitions = [
|
||||
{
|
||||
"obj": r,
|
||||
"title": _RECOGNITION_META.get(r.slug, {}).get("title", r.slug),
|
||||
"description": _RECOGNITION_META.get(r.slug, {}).get("description", ""),
|
||||
"palette_options": _RECOGNITION_META.get(r.slug, {}).get("palette_options", []),
|
||||
}
|
||||
for r in qs
|
||||
]
|
||||
return render(request, "apps/billboard/recognition.html", {
|
||||
"recognitions": qs,
|
||||
"recognition_items": recognitions,
|
||||
"page_class": "page-recognition",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_scroll_position(request, room_id):
|
||||
if request.method != "POST":
|
||||
|
||||
Reference in New Issue
Block a user