Compare commits
9 Commits
18898c7a0f
...
11c85d56d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c85d56d1 | ||
|
|
8bab26e003 | ||
|
|
bc78d2c470 | ||
|
|
2447315fd3 | ||
|
|
cde231d43c | ||
|
|
a0f8aeb791 | ||
|
|
2ca4e9d39f | ||
|
|
c71f4eb68c | ||
|
|
189d329e76 |
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
for slug, name, cols, rows in [
|
||||
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||
("billboard-my-contacts", "Contacts", 4, 3),
|
||||
("billboard-most-recent", "Most Recent", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||
)
|
||||
|
||||
|
||||
def remove_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug__in=[
|
||||
"billboard-my-scrolls",
|
||||
"billboard-my-contacts",
|
||||
"billboard-most-recent",
|
||||
]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0005_gameboard_applet_heights"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="applet",
|
||||
name="context",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("dashboard", "Dashboard"),
|
||||
("gameboard", "Gameboard"),
|
||||
("wallet", "Wallet"),
|
||||
("billboard", "Billboard"),
|
||||
],
|
||||
default="dashboard",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
|
||||
]
|
||||
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
# billboard-scroll belongs only to the billscroll page template, not the grid
|
||||
Applet.objects.filter(slug="billboard-scroll").delete()
|
||||
# Rename "My Contacts" → "Contacts"
|
||||
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
|
||||
|
||||
|
||||
def reverse_fix_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.get_or_create(
|
||||
slug="billboard-scroll",
|
||||
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
|
||||
)
|
||||
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0006_billboard_applets"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
|
||||
]
|
||||
@@ -4,10 +4,12 @@ class Applet(models.Model):
|
||||
DASHBOARD = "dashboard"
|
||||
GAMEBOARD = "gameboard"
|
||||
WALLET = "wallet"
|
||||
BILLBOARD = "billboard"
|
||||
CONTEXT_CHOICES = [
|
||||
(DASHBOARD, "Dashboard"),
|
||||
(GAMEBOARD, "Gameboard"),
|
||||
(WALLET, "Wallet"),
|
||||
(BILLBOARD, "Billboard"),
|
||||
]
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
184
src/apps/billboard/tests/integrated/test_billboard_views.py
Normal file
184
src/apps/billboard/tests/integrated/test_billboard_views.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
def _seed_billboard_applets():
|
||||
for slug, name, cols, rows in [
|
||||
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||
("billboard-my-contacts", "Contacts", 4, 3),
|
||||
("billboard-most-recent", "Most Recent", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||
)
|
||||
|
||||
|
||||
class BillboardViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@billboard.io")
|
||||
self.client.force_login(self.user)
|
||||
_seed_billboard_applets()
|
||||
|
||||
def test_uses_billboard_template(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
|
||||
|
||||
def test_passes_applets_context(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertIn("applets", response.context)
|
||||
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||
self.assertIn("billboard-my-scrolls", slugs)
|
||||
self.assertIn("billboard-my-contacts", slugs)
|
||||
self.assertIn("billboard-most-recent", slugs)
|
||||
|
||||
def test_passes_my_rooms_context(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertIn(room, response.context["my_rooms"])
|
||||
|
||||
def test_passes_recent_room_and_events(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
record(
|
||||
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertEqual(response.context["recent_room"], room)
|
||||
self.assertEqual(len(response.context["recent_events"]), 1)
|
||||
|
||||
def test_recent_events_capped_at_36(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
for i in range(40):
|
||||
record(
|
||||
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertEqual(len(response.context["recent_events"]), 36)
|
||||
|
||||
def test_recent_events_in_chronological_order(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
for _ in range(3):
|
||||
record(
|
||||
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
response = self.client.get("/billboard/")
|
||||
events = response.context["recent_events"]
|
||||
timestamps = [e.timestamp for e in events]
|
||||
self.assertEqual(timestamps, sorted(timestamps))
|
||||
|
||||
def test_recent_room_is_none_when_no_events(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertIsNone(response.context["recent_room"])
|
||||
self.assertEqual(list(response.context["recent_events"]), [])
|
||||
|
||||
|
||||
class ToggleBillboardAppletsTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@toggle.io")
|
||||
self.client.force_login(self.user)
|
||||
_seed_billboard_applets()
|
||||
|
||||
def test_toggle_hides_unchecked_applets(self):
|
||||
response = self.client.post(
|
||||
reverse("billboard:toggle_applets"),
|
||||
{"applets": ["billboard-my-scrolls"]},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
from apps.applets.models import UserApplet
|
||||
contacts = Applet.objects.get(slug="billboard-my-contacts")
|
||||
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
||||
self.assertFalse(ua.visible)
|
||||
|
||||
def test_toggle_returns_partial_on_htmx(self):
|
||||
response = self.client.post(
|
||||
reverse("billboard:toggle_applets"),
|
||||
{"applets": ["billboard-my-scrolls"]},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
|
||||
|
||||
|
||||
class BillscrollViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@billscroll.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
|
||||
def test_uses_room_scroll_template(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/room_scroll.html")
|
||||
|
||||
def test_passes_events_context(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertIn("events", response.context)
|
||||
self.assertEqual(response.context["events"].count(), 1)
|
||||
|
||||
def test_passes_page_class_billscroll(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertEqual(response.context["page_class"], "page-billscroll")
|
||||
|
||||
def test_passes_scroll_position_zero_when_none_saved(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertEqual(response.context["scroll_position"], 0)
|
||||
|
||||
def test_passes_saved_scroll_position_in_context(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertEqual(response.context["scroll_position"], 250)
|
||||
|
||||
|
||||
class SaveScrollPositionTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@savescroll.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_post_saves_scroll_position(self):
|
||||
self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 300},
|
||||
)
|
||||
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
|
||||
self.assertEqual(sp.position, 300)
|
||||
|
||||
def test_post_updates_existing_position(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||
self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 450},
|
||||
)
|
||||
self.assertEqual(
|
||||
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
|
||||
)
|
||||
|
||||
def test_post_returns_204(self):
|
||||
response = self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 100},
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
def test_post_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 100},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -6,5 +6,7 @@ app_name = "billboard"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||
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,8 +1,10 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render
|
||||
from django.db.models import Max, Q
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.applets.utils import applet_context
|
||||
from apps.drama.models import GameEvent, ScrollPosition
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite
|
||||
|
||||
|
||||
@@ -13,19 +15,72 @@ def billboard(request):
|
||||
Q(gate_slots__gamer=request.user) |
|
||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
||||
).distinct().order_by("-created_at")
|
||||
|
||||
recent_room = (
|
||||
Room.objects.filter(
|
||||
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
|
||||
)
|
||||
.annotate(last_event=Max("events__timestamp"))
|
||||
.filter(last_event__isnull=False)
|
||||
.order_by("-last_event")
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
recent_events = (
|
||||
list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1]
|
||||
if recent_room else []
|
||||
)
|
||||
|
||||
return render(request, "apps/billboard/billboard.html", {
|
||||
"my_rooms": my_rooms,
|
||||
"recent_room": recent_room,
|
||||
"recent_events": recent_events,
|
||||
"viewer": request.user,
|
||||
"applets": applet_context(request.user, "billboard"),
|
||||
"page_class": "page-billboard",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def toggle_billboard_applets(request):
|
||||
checked = request.POST.getlist("applets")
|
||||
for applet in Applet.objects.filter(context="billboard"):
|
||||
UserApplet.objects.update_or_create(
|
||||
user=request.user,
|
||||
applet=applet,
|
||||
defaults={"visible": applet.slug in checked},
|
||||
)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "apps/billboard/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "billboard"),
|
||||
})
|
||||
return redirect("billboard:billboard")
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def room_scroll(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
events = room.events.select_related("actor").all()
|
||||
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
|
||||
return render(request, "apps/billboard/room_scroll.html", {
|
||||
"room": room,
|
||||
"events": events,
|
||||
"viewer": request.user,
|
||||
"page_class": "page-billboard",
|
||||
"scroll_position": sp.position if sp else 0,
|
||||
"page_class": "page-billscroll",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_scroll_position(request, room_id):
|
||||
if request.method != "POST":
|
||||
from django.http import HttpResponseNotAllowed
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
room = Room.objects.get(id=room_id)
|
||||
position = int(request.POST.get("position", 0))
|
||||
ScrollPosition.objects.update_or_create(
|
||||
user=request.user, room=room,
|
||||
defaults={"position": position},
|
||||
)
|
||||
from django.http import HttpResponse
|
||||
return HttpResponse(status=204)
|
||||
|
||||
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0 on 2026-03-24 21:36
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('drama', '0001_initial'),
|
||||
('epic', '0006_table_status_and_table_seat'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ScrollPosition',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to='epic.room')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'room')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -73,7 +73,7 @@ class GameEvent(models.Model):
|
||||
}
|
||||
code = d.get("role", "?")
|
||||
role = d.get("role_display") or _role_names.get(code, code)
|
||||
return f"starts as {role}"
|
||||
return f"elects to start as {role}"
|
||||
if self.verb == self.ROLES_REVEALED:
|
||||
return "All roles assigned"
|
||||
return self.verb
|
||||
@@ -83,6 +83,25 @@ class GameEvent(models.Model):
|
||||
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
||||
|
||||
|
||||
class ScrollPosition(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name="scroll_positions",
|
||||
)
|
||||
room = models.ForeignKey(
|
||||
"epic.Room", on_delete=models.CASCADE,
|
||||
related_name="scroll_positions",
|
||||
)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("user", "room")]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} @ {self.room.name}: {self.position}px"
|
||||
|
||||
|
||||
def record(room, verb, actor=None, **data):
|
||||
"""Record a game event in the drama log."""
|
||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
@@ -42,3 +43,31 @@ class GameEventModelTest(TestCase):
|
||||
def test_str_without_actor_shows_system(self):
|
||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||
self.assertIn("system", str(event))
|
||||
|
||||
|
||||
class ScrollPositionModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="reader@test.io")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_can_save_scroll_position(self):
|
||||
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
|
||||
self.assertEqual(ScrollPosition.objects.count(), 1)
|
||||
self.assertEqual(sp.position, 150)
|
||||
|
||||
def test_default_position_is_zero(self):
|
||||
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
|
||||
self.assertEqual(sp.position, 0)
|
||||
|
||||
def test_unique_per_user_and_room(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||
with self.assertRaises(IntegrityError):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||
|
||||
def test_upsert_updates_existing_position(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||
ScrollPosition.objects.update_or_create(
|
||||
user=self.user, room=self.room,
|
||||
defaults={"position": 200},
|
||||
)
|
||||
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import time
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room, GateSlot
|
||||
from apps.lyric.models import User
|
||||
@@ -18,8 +21,17 @@ class BillboardScrollTest(FunctionalTest):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.founder = User.objects.create(email="founder@scroll.io")
|
||||
self.other = User.objects.create(email="other@scroll.io")
|
||||
for slug, name, cols, rows in [
|
||||
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||
("billboard-my-contacts", "Contacts", 4, 3),
|
||||
("billboard-most-recent", "Most Recent", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||
)
|
||||
self.founder = User.objects.create(email="founder@test.io")
|
||||
self.other = User.objects.create(email="other@test.io")
|
||||
self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder)
|
||||
# Simulate two gate fills — one by founder, one by the other gamer
|
||||
record(
|
||||
@@ -44,7 +56,7 @@ class BillboardScrollTest(FunctionalTest):
|
||||
|
||||
def test_footer_scroll_icon_leads_to_billboard_with_rooms(self):
|
||||
# Founder logs in and lands on the dashboard
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.live_server_url + "/")
|
||||
|
||||
# Footer contains a scroll icon link pointing to /billboard/
|
||||
@@ -65,7 +77,7 @@ class BillboardScrollTest(FunctionalTest):
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_scroll_shows_human_readable_event_log(self):
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
|
||||
# Click the room link to reach the scroll
|
||||
@@ -86,14 +98,14 @@ class BillboardScrollTest(FunctionalTest):
|
||||
self.assertIn("deposits a Free Token for slot 2 (7 days)", scroll.text)
|
||||
|
||||
# Role selection event is rendered as prose
|
||||
self.assertIn("starts as Player", scroll.text)
|
||||
self.assertIn("elects to start as Player", scroll.text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 3 — current user's events are right-aligned; others' are left #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_scroll_aligns_own_events_right_and_others_left(self):
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance")
|
||||
@@ -111,3 +123,164 @@ class BillboardScrollTest(FunctionalTest):
|
||||
|
||||
# The other gamer's event mentions their display name
|
||||
self.assertIn("other", theirs_events[0].text)
|
||||
|
||||
|
||||
class BillscrollPositionTest(FunctionalTest):
|
||||
"""
|
||||
FT: the user's scroll position in a billscroll is saved to the server
|
||||
and restored when they return to the same scroll from any device/session.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.founder = User.objects.create(email="founder@scrollpos.io")
|
||||
self.room = Room.objects.create(name="Persistent Chamber", owner=self.founder)
|
||||
# Enough events to make #id_drama_scroll scrollable
|
||||
for i in range(20):
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||
slot_number=(i % 6) + 1, token_type="coin",
|
||||
token_display=f"Coin-{i}", renewal_days=7,
|
||||
)
|
||||
|
||||
def test_scroll_position_persists_across_sessions(self):
|
||||
# 1. Log in and navigate to the room's billscroll
|
||||
self.create_pre_authenticated_session("founder@scrollpos.io")
|
||||
self.browser.get(
|
||||
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||
)
|
||||
scroll_el = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
|
||||
# 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase),
|
||||
# set position, and dispatch scroll event to trigger the debounced save.
|
||||
# JS saves scrollTop + clientHeight (bottom-of-viewport); forced height is 150px.
|
||||
scroll_top = 100
|
||||
forced_height = 150
|
||||
self.browser.execute_script("""
|
||||
var el = arguments[0];
|
||||
el.style.overflow = 'auto';
|
||||
el.style.height = '150px';
|
||||
el.scrollTop = arguments[1];
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
""", scroll_el, scroll_top)
|
||||
|
||||
# 3. Wait for debounce (800ms) + fetch to complete
|
||||
time.sleep(3)
|
||||
|
||||
# 4. Navigate away and back in a fresh session
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
self.create_pre_authenticated_session("founder@scrollpos.io")
|
||||
self.browser.get(
|
||||
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||
)
|
||||
|
||||
# 5. The saved position is reflected in the page's data attribute
|
||||
scroll_el = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
buffer_px = self.browser.execute_script(
|
||||
"return Math.round(parseFloat(getComputedStyle(document.documentElement).fontSize) * 2.5)"
|
||||
)
|
||||
restored = int(scroll_el.get_attribute("data-scroll-position"))
|
||||
self.assertEqual(restored, scroll_top + forced_height + buffer_px)
|
||||
|
||||
|
||||
class BillboardAppletsTest(FunctionalTest):
|
||||
"""
|
||||
FT: billboard page renders three applets in the grid — My Scrolls,
|
||||
My Contacts, and Most Recent — with a functioning gear menu.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.founder = User.objects.create(email="founder@test.io")
|
||||
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
||||
for slug, name, cols, rows in [
|
||||
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||
("billboard-my-contacts", "Contacts", 4, 3),
|
||||
("billboard-most-recent", "Most Recent", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||
)
|
||||
|
||||
def test_billboard_shows_three_applets(self):
|
||||
# 1. Log in, navigate to billboard
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
# 2. Assert all three applet sections present
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls")
|
||||
)
|
||||
self.browser.find_element(By.ID, "id_applet_billboard_my_contacts")
|
||||
self.browser.find_element(By.ID, "id_applet_billboard_most_recent")
|
||||
|
||||
def test_billboard_my_scrolls_lists_rooms(self):
|
||||
# 1. Log in, navigate to billboard
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
# 2. My Scrolls applet contains a link to the room's scroll
|
||||
self.wait_for(
|
||||
lambda: self.assertIn(
|
||||
"Arcane Assembly",
|
||||
self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls").text,
|
||||
)
|
||||
)
|
||||
|
||||
def test_billboard_gear_btn_opens_applet_menu(self):
|
||||
# 1. Log in, navigate to billboard
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
# 2. Gear button is visible
|
||||
gear_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn")
|
||||
)
|
||||
# 3. Menu is hidden before click
|
||||
menu = self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
||||
self.assertFalse(menu.is_displayed())
|
||||
# 4. Clicking gear opens the menu (JS click bypasses kit-bag overlap in headless)
|
||||
self.browser.execute_script("arguments[0].click()", gear_btn)
|
||||
self.wait_for_slow(lambda: self.assertTrue(menu.is_displayed()))
|
||||
|
||||
|
||||
class BillscrollAppletsTest(FunctionalTest):
|
||||
"""
|
||||
FT: billscroll page renders as a single full-width applet that fills
|
||||
the viewport aperture and contains the room's drama events.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.founder = User.objects.create(email="founder@billtest.io")
|
||||
self.room = Room.objects.create(name="Spectral Council", owner=self.founder)
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
|
||||
def test_billscroll_shows_full_width_applet(self):
|
||||
# 1. Log in, navigate to the room's scroll
|
||||
self.create_pre_authenticated_session("founder@billtest.io")
|
||||
self.browser.get(
|
||||
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||
)
|
||||
# 2. The full-width applet section is present
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_applet_billboard_scroll")
|
||||
)
|
||||
|
||||
def test_billscroll_applet_contains_drama_events(self):
|
||||
# 1. Log in, navigate to the room's scroll
|
||||
self.create_pre_authenticated_session("founder@billtest.io")
|
||||
self.browser.get(
|
||||
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||
)
|
||||
# 2. Drama scroll is inside the applet and shows the event
|
||||
scroll = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
self.assertIn("Coin-on-a-String", scroll.text)
|
||||
|
||||
@@ -77,16 +77,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
#id_dash_applet_menu { @extend %applet-menu; }
|
||||
#id_game_applet_menu { @extend %applet-menu; }
|
||||
#id_wallet_applet_menu { @extend %applet-menu; }
|
||||
#id_room_menu { @extend %applet-menu; }
|
||||
#id_dash_applet_menu { @extend %applet-menu; }
|
||||
#id_game_applet_menu { @extend %applet-menu; }
|
||||
#id_wallet_applet_menu { @extend %applet-menu; }
|
||||
#id_room_menu { @extend %applet-menu; }
|
||||
#id_billboard_applet_menu { @extend %applet-menu; }
|
||||
|
||||
// Page-level gear buttons — fixed to viewport bottom-right
|
||||
.gameboard-page,
|
||||
.dashboard-page,
|
||||
.wallet-page,
|
||||
.room-page {
|
||||
.room-page,
|
||||
.billboard-page {
|
||||
> .gear-btn {
|
||||
position: fixed;
|
||||
bottom: 4.2rem;
|
||||
@@ -97,7 +99,8 @@
|
||||
|
||||
#id_dash_applet_menu,
|
||||
#id_game_applet_menu,
|
||||
#id_wallet_applet_menu {
|
||||
#id_wallet_applet_menu,
|
||||
#id_billboard_applet_menu {
|
||||
position: fixed;
|
||||
bottom: 6.6rem;
|
||||
right: 1rem;
|
||||
@@ -111,7 +114,8 @@
|
||||
.gameboard-page,
|
||||
.dashboard-page,
|
||||
.wallet-page,
|
||||
.room-page {
|
||||
.room-page,
|
||||
.billboard-page {
|
||||
> .gear-btn {
|
||||
right: calc(#{$sidebar-w} + 0.5rem);
|
||||
bottom: 4.2rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
||||
@@ -121,13 +125,64 @@
|
||||
|
||||
#id_dash_applet_menu,
|
||||
#id_game_applet_menu,
|
||||
#id_wallet_applet_menu {
|
||||
#id_wallet_applet_menu,
|
||||
#id_billboard_applet_menu {
|
||||
right: calc(#{$sidebar-w} + 1rem);
|
||||
bottom: 6.6rem; // same as portrait, just shifted right of footer sidebar
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Applet box visual shell (reusable outside the grid) ────
|
||||
%applet-box {
|
||||
border:
|
||||
0.2rem solid rgba(var(--secUser), 0.5),
|
||||
;
|
||||
box-shadow:
|
||||
inset -0.125rem -0.125rem 0 rgba(var(--ninUser), 0.125),
|
||||
inset 0.125rem 0.125rem 0 rgba(0, 0, 0, 0.8)
|
||||
;
|
||||
background-color: rgba(0, 0, 0, 0.125);
|
||||
border-radius: 0.75rem;
|
||||
position: relative;
|
||||
padding: 0.75rem 0.75rem 0.75rem 2.5rem;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
> h2 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
padding-right: 0.2rem;
|
||||
color: rgba(var(--secUser), 1);
|
||||
text-shadow:
|
||||
1px 1px 0 rgba(255, 255, 255, 0.06),
|
||||
-0.06rem -0.06rem 0 rgba(0, 0, 0, 0.25)
|
||||
;
|
||||
background-color: rgba(0, 0, 0, 0.125);
|
||||
box-shadow:
|
||||
0 0 0.5rem rgba(var(--priUser), 0.5),
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.5),
|
||||
;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
a { color: rgba(var(--terUser), 1); text-decoration: none; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Applets grid (shared across all boards) ────────────────
|
||||
%applets-grid {
|
||||
container-type: inline-size;
|
||||
@@ -150,52 +205,7 @@
|
||||
);
|
||||
|
||||
section {
|
||||
border:
|
||||
0.2rem solid rgba(var(--secUser), 0.5),
|
||||
;
|
||||
box-shadow:
|
||||
inset -0.125rem -0.125rem 0 rgba(var(--ninUser), 0.125),
|
||||
inset 0.125rem 0.125rem 0 rgba(0, 0, 0, 0.8)
|
||||
;
|
||||
background-color: rgba(0, 0, 0, 0.125);
|
||||
border-radius: 0.75rem;
|
||||
position: relative;
|
||||
padding: 0.75rem 0.75rem 0.75rem 2.5rem;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
> h2 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
padding-right: 0.2rem;
|
||||
color: rgba(var(--secUser), 1);
|
||||
text-shadow:
|
||||
1px 1px 0 rgba(255, 255, 255, 0.06),
|
||||
-0.06rem -0.06rem 0 rgba(0, 0, 0, 0.25)
|
||||
;
|
||||
background-color: rgba(0, 0, 0, 0.125);
|
||||
box-shadow:
|
||||
0 0 0.5rem rgba(var(--priUser), 0.5),
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.5),
|
||||
;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
a { color: rgba(var(--terUser), 1); text-decoration: none; }
|
||||
}
|
||||
@extend %applet-box;
|
||||
grid-column: span var(--applet-cols, 12);
|
||||
grid-row: span var(--applet-rows, 3);
|
||||
|
||||
@@ -205,6 +215,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
#id_applets_container { @extend %applets-grid; }
|
||||
#id_game_applets_container { @extend %applets-grid; }
|
||||
#id_wallet_applets_container { @extend %applets-grid; }
|
||||
#id_applets_container { @extend %applets-grid; }
|
||||
#id_game_applets_container { @extend %applets-grid; }
|
||||
#id_wallet_applets_container { @extend %applets-grid; }
|
||||
#id_billboard_applets_container { @extend %applets-grid; }
|
||||
|
||||
@@ -439,6 +439,16 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.forthcoming {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#id_guard_portal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
html:has(body.page-billboard) {
|
||||
// ── Shared aperture fill for both billboard pages ──────────────────────────
|
||||
|
||||
%billboard-page-base {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html:has(body.page-billboard),
|
||||
html:has(body.page-billscroll) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.page-billboard {
|
||||
body.page-billboard,
|
||||
body.page-billscroll {
|
||||
overflow: hidden;
|
||||
|
||||
.container {
|
||||
@@ -19,10 +31,122 @@ body.page-billboard {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Billboard page (three-applet grid) ─────────────────────────────────────
|
||||
|
||||
.billboard-page {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding: 0.75rem;
|
||||
@extend %billboard-page-base;
|
||||
}
|
||||
|
||||
// ── Billscroll page (single full-aperture applet) ──────────────────────────
|
||||
|
||||
.billscroll-page {
|
||||
@extend %billboard-page-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
|
||||
// The single scroll applet stretches to fill the remaining aperture
|
||||
.applet-billboard-scroll {
|
||||
@extend %applet-box;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
#id_drama_scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
.scroll-buffer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
padding: 2rem 0 1rem;
|
||||
opacity: 0.4;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
.scroll-buffer-text {
|
||||
letter-spacing: 0.33em;
|
||||
}
|
||||
|
||||
.scroll-buffer-dots {
|
||||
display: inline-flex;
|
||||
letter-spacing: 0;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
width: 0.7em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Billboard applet placement ─────────────────────────────────────────────
|
||||
// Explicit placement: My Scrolls + Contacts stack left, Most Recent fills right.
|
||||
// Portrait override (container query) restores stacked full-width layout.
|
||||
|
||||
#id_billboard_applets_container {
|
||||
#id_applet_billboard_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
|
||||
#id_applet_billboard_my_contacts { grid-column: 1 / span 4; grid-row: 4 / span 3; }
|
||||
#id_applet_billboard_most_recent { grid-column: 5 / span 8; grid-row: 1 / span 6; }
|
||||
|
||||
@container (max-width: 550px) {
|
||||
#id_applet_billboard_my_scrolls,
|
||||
#id_applet_billboard_my_contacts,
|
||||
#id_applet_billboard_most_recent {
|
||||
grid-column: 1 / span 12;
|
||||
grid-row: span var(--applet-rows, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Most Recent applet — scrollable drama feed ─────────────────────────────
|
||||
|
||||
#id_applet_billboard_most_recent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.most-recent-room-link {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#id_drama_scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.most-recent-load-more {
|
||||
display: block;
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ── My Scrolls list ────────────────────────────────────────────────────────
|
||||
|
||||
#id_applet_billboard_my_scrolls {
|
||||
.scroll-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid rgba(var(--priUser), 0.15);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
a { text-decoration: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{% load lyric_extras %}
|
||||
<section
|
||||
id="id_applet_billboard_most_recent"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2>Most Recent</h2>
|
||||
{% if recent_room %}
|
||||
<a href="{% url 'billboard:scroll' recent_room.id %}" class="most-recent-room-link">{{ recent_room.name }}</a>
|
||||
<section id="id_drama_scroll" class="drama-scroll">
|
||||
<a href="{% url 'billboard:scroll' recent_room.id %}" class="most-recent-load-more">Load more….</a>
|
||||
{% for event in recent_events %}
|
||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
||||
<span class="event-body">
|
||||
<strong>{{ event.actor|display_name }}</strong>
|
||||
{{ event.to_prose }}<br>
|
||||
<time class="event-time" datetime="{{ event.timestamp|date:'c' }}">
|
||||
{{ event.timestamp|date:"N j, g:i a" }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="event-empty"><small>No events yet.</small></p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<p><small>No recent activity.</small></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function() {
|
||||
var scroll = document.getElementById('id_drama_scroll');
|
||||
if (scroll) scroll.scrollTop = scroll.scrollHeight;
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<section
|
||||
id="id_applet_billboard_my_contacts"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2>Contacts</h2>
|
||||
{% include "core/_partials/_forthcoming.html" %}
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
<section
|
||||
id="id_applet_billboard_my_scrolls"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2>My Scrolls</h2>
|
||||
<ul class="scroll-list">
|
||||
{% for room in my_rooms %}
|
||||
<li>
|
||||
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li><small>No scrolls yet.</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -0,0 +1,59 @@
|
||||
<section id="id_applet_billboard_scroll" class="applet-billboard-scroll">
|
||||
<h2>{{ room.name }}</h2>
|
||||
{% include "core/_partials/_scroll.html" %}
|
||||
</section>
|
||||
<script>
|
||||
(function() {
|
||||
var scroll = document.getElementById('id_drama_scroll');
|
||||
if (!scroll) return;
|
||||
|
||||
// Defer dimension-dependent work until after flex layout resolves.
|
||||
// Inline scripts can run before nested flex heights are computed, producing
|
||||
// wrong scrollHeight/clientHeight values (symptom: incorrect marginTop on mobile).
|
||||
requestAnimationFrame(function() {
|
||||
// Push buffer so its top aligns with the bottom of the aperture when all
|
||||
// events fit within the viewport (no natural scrolling).
|
||||
var buffer = scroll.querySelector('.scroll-buffer');
|
||||
if (buffer) {
|
||||
var eventsHeight = scroll.scrollHeight - buffer.offsetHeight;
|
||||
var gap = scroll.clientHeight - eventsHeight;
|
||||
if (gap > 0) {
|
||||
buffer.style.marginTop = gap + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// Restore: position stored is bottom-of-viewport; subtract clientHeight to align it
|
||||
scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight);
|
||||
});
|
||||
|
||||
// Animate "What happens next. . . ?" buffer dots — 4th span shows '?'
|
||||
var dotsWrap = scroll.querySelector('.scroll-buffer-dots');
|
||||
if (dotsWrap) {
|
||||
var dots = dotsWrap.querySelectorAll('span');
|
||||
var n = 0;
|
||||
setInterval(function() {
|
||||
dots.forEach(function(d, i) { d.textContent = i < n ? (i === 3 ? '?' : '.') : ''; });
|
||||
n = (n + 1) % 5;
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Debounced save on scroll — store bottom-of-viewport so the last-read line is restored
|
||||
var saveTimer;
|
||||
scroll.addEventListener('scroll', function() {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(function() {
|
||||
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
var token = csrfToken ? csrfToken.value : '';
|
||||
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
fetch("{% url 'billboard:save_scroll_position' room.id %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': token,
|
||||
},
|
||||
body: 'position=' + Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5),
|
||||
});
|
||||
}, 800);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
27
src/templates/apps/billboard/_partials/_applets.html
Normal file
27
src/templates/apps/billboard/_partials/_applets.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div id="id_billboard_applets_container">
|
||||
<div id="id_billboard_applet_menu" style="display:none;">
|
||||
<form
|
||||
hx-post="{% url "billboard:toggle_applets" %}"
|
||||
hx-target="#id_billboard_applets_container"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{% for entry in applets %}
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="applets"
|
||||
value="{{ entry.applet.slug }}"
|
||||
{% if entry.visible %}checked{% endif %}
|
||||
>
|
||||
{{ entry.applet.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<div class="menu-btns">
|
||||
<button type="submit" class="btn btn-confirm">OK</button>
|
||||
<button type="button" class="btn btn-cancel applet-menu-cancel">NVM</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% include "apps/applets/_partials/_applets.html" %}
|
||||
</div>
|
||||
@@ -5,15 +5,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="billboard-page">
|
||||
<h2>My Scrolls</h2>
|
||||
<ul class="game-list">
|
||||
{% for room in my_rooms %}
|
||||
<li>
|
||||
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li><small>No scrolls yet.</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% include "apps/applets/_partials/_gear.html" with menu_id="id_billboard_applet_menu" %}
|
||||
{% include "apps/billboard/_partials/_applets.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="billboard-page">
|
||||
<h2>{{ room.name }}</h2>
|
||||
{% include "core/_partials/_scroll.html" %}
|
||||
{% csrf_token %}
|
||||
<div class="billscroll-page">
|
||||
{% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
1
src/templates/core/_partials/_forthcoming.html
Normal file
1
src/templates/core/_partials/_forthcoming.html
Normal file
@@ -0,0 +1 @@
|
||||
<p class="forthcoming"><small>[Feature forthcoming]</small></p>
|
||||
@@ -1,5 +1,5 @@
|
||||
{% load lyric_extras %}
|
||||
<section id="id_drama_scroll" class="drama-scroll">
|
||||
<section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
|
||||
{% for event in events %}
|
||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
||||
<span class="event-body">
|
||||
@@ -13,4 +13,12 @@
|
||||
{% empty %}
|
||||
<p class="event-empty"><small>No events yet.</small></p>
|
||||
{% endfor %}
|
||||
<div class="scroll-buffer" aria-hidden="true">
|
||||
<span class="scroll-buffer-text">What</span>
|
||||
<span class="scroll-buffer-text quaUser"> happens</span>
|
||||
<span class="scroll-buffer-text terUser"> next</span>
|
||||
<span class="scroll-buffer-dots">
|
||||
<span></span><span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user