From 0800736b9e6f04b3eb56809ee135da71d6280323 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Tue, 20 Oct 2026 21:36:55 +0200 Subject: [PATCH] =?UTF-8?q?Spilmenu=20p=C3=A5=20/host/:=20kort=20pr.=20spi?= =?UTF-8?q?l=20og=20intro=20med=20speaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /host/ er nu en menu bygget af registret (menu.js): et kort pr. spil med navn, tagline, ikon, antal spillere og ca. varighed. Vælges et spil, kommer introen med trinene, spillets egne indstillinger (Fup og Fakta's pakker inkl. arbejde og fest, Tegn og Gæts regler til/fra), Start og Tilbage. - Fjernbetjening og tastatur: pilene flytter fokus til det nærmeste valgbare i den retning, Enter/OK vælger, Escape/Backspace/TV'ets Tilbage og browserens tilbage går til menuen. Alt er knapper, også indstillingerne, så Enter altid gælder det fokuserede (fejlen fra 64aac36 kan ikke komme igen ad den vej). Mus og touch virker som før. - Speakeren læser introen op: /host/intro/ giver lyden til den faste tekst (navn, tagline og trin) med standardstemmen, {} uden stemme. - Uden JavaScript: én startknap pr. spil fra registret. - Den gamle pakke-formular og dens CSS er væk. Co-Authored-By: Claude Opus 5.5 --- party/static/party/menu.js | 164 ++++++++++++++++++++++++++++ party/static/party/party.css | 65 ++++++++--- party/templates/party/host_new.html | 46 ++++---- party/tests/test_games.py | 31 ++++++ party/tests/test_tegn.py | 7 +- party/urls.py | 1 + party/views.py | 14 ++- 7 files changed, 284 insertions(+), 44 deletions(-) create mode 100644 party/static/party/menu.js diff --git a/party/static/party/menu.js b/party/static/party/menu.js new file mode 100644 index 0000000..237f173 --- /dev/null +++ b/party/static/party/menu.js @@ -0,0 +1,164 @@ +/* Spilmenuen på storskærmen (/host/): et kort pr. spil fra registret (party/games.py), og en intro med trin, + spillets egne indstillinger, Start og Tilbage. Fjernbetjening/tastatur: pilene flytter fokus mellem det, man + kan vælge, Enter/OK vælger, Tilbage/Escape går til menuen. Mus og touch virker som altid. + Eksporterer window.WPPMenu = {render, mount}. */ +(function () { + 'use strict'; + var WPP = window.WPP, esc = WPP.esc; + + function facts(g) { + var players = g.min_players === g.max_players ? g.min_players : g.min_players + '-' + g.max_players; + return '👥 ' + players + ' spillere⏱ ca. ' + esc(g.minutes) + ' min'; + } + + function menuScreen(games) { + return '

Weirsøe Party Protocol

Vælg et spil

' + + '
' + games.map(function (g, i) { + return ''; + }).join('') + '
'; + } + + function introScreen(g, values) { + var sets = (g.settings || []).map(function (s) { + var cur = values[s.name] != null ? values[s.name] : s.default; + var hint = s.options.filter(function (o) { return o.value === cur; })[0]; + return '
' + esc(s.label) + '
' + + s.options.map(function (o) { + return ''; + }).join('') + '
' + (hint && hint.hint ? '

' + esc(hint.hint) + '

' : '') + '
'; + }).join(''); + /* nøgler pr. del: et skift af indstilling tegner kun den række igen (ingen ny indgangsanimation) */ + return '
' + + '
' + + '

' + WPP.brandHtml(g.name) + '

' + esc(g.tagline) + '

' + + '

' + facts(g) + '

' + + '
    ' + g.steps.map(function (s, i) { return '
  1. ' + esc(s) + '
  2. '; }).join('') + '
' + + (sets ? '
' + sets + '
' : '') + + '
' + + '
'; + } + + /* view = {screen: 'menu'} eller {screen: 'intro', key, values} */ + function render(root, view) { + var games = WPP.games, g = view.screen === 'intro' && WPP.game(view.key); + var sh = WPP.shell(root); + sh.screen.className = 'screen mn mn-' + (g ? 'intro' : 'menu'); + WPP.patch(sh.screen, '
' + + (g ? introScreen(g, view.values || {}) : menuScreen(games)) + '
'); + } + + /* pilene: nærmeste valgbare element i den retning (målt fra midten), så både kort i et gitter og knapper + i rækker virker uden at vide, hvordan skærmen er bygget */ + function move(root, key) { + var items = Array.prototype.slice.call(root.querySelectorAll('[data-nav]')); + if (!items.length) return; + var cur = document.activeElement; + if (items.indexOf(cur) < 0) { items[0].focus(); return; } + var r = cur.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2; + var dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[key]; + var best = null, bestScore = Infinity; + items.forEach(function (el) { + if (el === cur) return; + var b = el.getBoundingClientRect(), dx = b.left + b.width / 2 - cx, dy = b.top + b.height / 2 - cy; + var along = dx * dir[0] + dy * dir[1], across = Math.abs(dx * dir[1] - dy * dir[0]); + if (along <= 1) return; + var score = along + across * 2; + if (score < bestScore) { bestScore = score; best = el; } + }); + if (best) best.focus(); + } + + var BACK_KEYS = { Escape: 1, Backspace: 1, GoBack: 1, BrowserBack: 1 }; + var BACK_CODES = { 461: 1, 10009: 1 }; /* fjernbetjeningens Tilbage på nogle TV'er */ + + function mount(root) { + var form = document.getElementById('mn-form'); + var view = { screen: 'menu' }; + + function focusFirst(sel) { + var el = root.querySelector(sel) || root.querySelector('[data-nav]'); + if (el) el.focus(); + } + function speak(key) { + WPP.voice.stop(); + if (!window.fetch) return; + fetch('/host/intro/' + encodeURIComponent(key), { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : {}; }) + .then(function (a) { if (a && a.url && view.screen === 'intro' && view.key === key) WPP.voice.now([a.url]); }) + .catch(function () { /* uden stemme */ }); + } + function show(next, push) { + var from = view; + view = next; + render(root, view); + if (view.screen === 'intro') { + if (push) try { history.pushState({ intro: view.key }, '', '#' + view.key); } catch (e) { /* */ } + if (from.screen !== 'intro' || from.key !== view.key) speak(view.key); + focusFirst(view.focusSet ? '[data-set="' + view.focusSet + '"][aria-pressed=true]' : '[data-start]'); + } else { + WPP.voice.stop(); + focusFirst(from.key ? '[data-pick="' + from.key + '"]' : null); + } + } + function back() { + if (view.screen !== 'intro') return; + if (history.state && history.state.intro) history.back(); else show({ screen: 'menu' }); + } + function start() { + var g = WPP.game(view.key); + if (!g || !form) return; + WPP.voice.stop(); + form.querySelector('[name=game]').value = g.key; + Array.prototype.forEach.call(form.querySelectorAll('[data-setting]'), function (el) { el.parentNode.removeChild(el); }); + (g.settings || []).forEach(function (s) { + var inp = document.createElement('input'); + inp.type = 'hidden'; inp.name = s.name; inp.setAttribute('data-setting', ''); + inp.value = view.values[s.name] != null ? view.values[s.name] : s.default; + form.appendChild(inp); + }); + root.classList.add('mn-busy'); + form.submit(); + } + + root.addEventListener('click', function (e) { + var b = e.target.closest('button'); + if (!b || !root.contains(b)) return; + if (b.hasAttribute('data-pick')) show({ screen: 'intro', key: b.getAttribute('data-pick'), values: {} }, true); + else if (b.hasAttribute('data-back')) back(); + else if (b.hasAttribute('data-start')) start(); + else if (b.hasAttribute('data-set')) { + var values = Object.assign({}, view.values); + values[b.getAttribute('data-set')] = b.getAttribute('data-val'); + show({ screen: 'intro', key: view.key, values: values, focusSet: b.getAttribute('data-set') }); + } + }); + window.addEventListener('keydown', function (e) { + if (e.key && e.key.indexOf('Arrow') === 0) { e.preventDefault(); move(root, e.key); return; } + if (view.screen === 'intro' && (BACK_KEYS[e.key] || BACK_CODES[e.keyCode])) { e.preventDefault(); back(); } + }); + window.addEventListener('popstate', function (e) { + var key = (e.state && e.state.intro) || (location.hash || '').slice(1); + if (WPP.game(key)) show({ screen: 'intro', key: key, values: {} }); + else show({ screen: 'menu' }); + }); + /* bfcache: kommer man tilbage fra lobbyen, må siden ikke stå som "starter…" */ + window.addEventListener('pageshow', function () { root.classList.remove('mn-busy'); }); + + var key = (location.hash || '').slice(1); + if (WPP.game(key)) { + try { history.replaceState({ intro: key }, '', '#' + key); } catch (e) { /* */ } + show({ screen: 'intro', key: key, values: {} }); + } else show({ screen: 'menu' }); + } + + window.WPPMenu = { render: render, mount: mount }; + if (!window.WPP_HARNESS) { + var go = function () { var r = document.getElementById('menu'); if (r) mount(r); }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else go(); + } +})(); diff --git a/party/static/party/party.css b/party/static/party/party.css index 49d40fc..c53808e 100644 --- a/party/static/party/party.css +++ b/party/static/party/party.css @@ -900,21 +900,8 @@ button, input { font-family: inherit; } .rl-row b { display: block; } .rl-row span { font-size: .95rem; color: var(--muted); } -/* ---------- nyt spil: vælg spørgsmålspakke ---------- */ -.new-game { display: flex; flex-direction: column; align-items: center; gap: 20px; } -.new-game .games { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; } -.packs { display: flex; gap: 8px; margin: 0; padding: 0; border: 0; flex-wrap: wrap; justify-content: center; } -.packs legend { width: 100%; margin-bottom: 8px; color: var(--muted); font-weight: 600; } -.packs label { cursor: pointer; } -.packs input { position: absolute; opacity: 0; pointer-events: none; } -.packs span { display: inline-block; padding: 10px 20px; border-radius: 999px; font-weight: 700; background: var(--card); box-shadow: inset 0 0 0 2px var(--card-b); } -.packs input:checked + span { background: var(--yellow); color: var(--navy); box-shadow: 0 4px 0 var(--yellow-d); } -.packs input:focus-visible + span { outline: 3px solid #fff; outline-offset: 2px; } -.more-packs { width: 100%; margin-top: 6px; } -.more-packs summary { cursor: pointer; color: var(--muted); font-size: .95rem; list-style: none; } -.more-packs summary::-webkit-details-marker { display: none; } -.more-packs[open] summary { margin-bottom: 8px; } -.pack-hint { margin: 8px 0 0; font-size: .9rem; color: var(--muted); } +/* ---------- nyt spil uden JavaScript: én startknap pr. spil ---------- */ +.new-game { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; } /* fest: tår */ .rv-res .sips { display: flex; flex-wrap: wrap; justify-content: center; gap: .6em; font-size: 1.35em; margin-top: .4em; animation: rise .4s .7s both; } @@ -1009,3 +996,51 @@ button, input { font-family: inherit; } .tg-end.survived .stamp { color: var(--green); border-color: var(--green); } .tg-end.survived .tg-vsbox { border-color: var(--green); } @media (prefers-reduced-motion: reduce) { .tg-bigicon, .tg-entry.now, .tg-intro, .tg-theme { animation: none; } } + +/* ---------- spilmenuen (/host/): kort pr. spil og intro; fjernbetjening, tastatur og mus ---------- */ +.mn { min-height: 100vh; min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 32px 24px; } +.mn-main { width: 100%; max-width: 1400px; } +.mn-head { text-align: center; margin-bottom: clamp(20px, 4vh, 48px); } +.mn-head h1 { margin: 0; font-size: clamp(2rem, 5vw, 4.4rem); line-height: 1; text-shadow: 0 6px 0 rgba(0, 0, 0, .35); } +.mn-head p { margin: 10px 0 0; font-size: clamp(1.2rem, 2.4vw, 2rem); color: var(--muted); font-weight: 600; } +.mn-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: clamp(16px, 3vw, 40px); } +.mn-card { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: clamp(20px, 3vw, 40px) 24px; border: 0; border-radius: 28px; + background: var(--card); box-shadow: inset 0 0 0 3px var(--card-b), 0 10px 30px rgba(0, 0, 0, .35); color: var(--ink); font: inherit; cursor: pointer; + transition: transform .15s ease, box-shadow .15s ease, background .15s ease; animation: mn-in .4s ease both; animation-delay: calc(var(--i, 0) * 80ms); } +.mn-card img { width: clamp(90px, 12vw, 160px); height: auto; filter: drop-shadow(0 6px 0 rgba(0, 0, 0, .3)); } +.mn-name { font-size: clamp(1.8rem, 3.6vw, 3.2rem); font-weight: 700; line-height: 1; } +.mn-name .b-og, .mn-iname .b-og { font-size: .6em; } +.mn-tag { font-size: clamp(1rem, 1.6vw, 1.4rem); color: var(--muted); text-align: center; margin: 0; } +.mn-facts { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin: 0; } +.mn-fact { padding: 6px 14px; border-radius: 999px; background: var(--faint); font-weight: 600; font-size: clamp(.95rem, 1.3vw, 1.15rem); } +.mn-card:hover, .mn-card:focus { background: rgba(255, 255, 255, .16); transform: translateY(-4px) scale(1.02); outline: none; + box-shadow: inset 0 0 0 4px var(--yellow), 0 16px 40px rgba(0, 0, 0, .45); } +.mn-intro { display: flex; flex-direction: column; gap: clamp(16px, 3vh, 32px); max-width: 1100px; margin: 0 auto; animation: mn-in .35s ease both; } +.mn-intro-top { display: flex; gap: 28px; align-items: center; } +.mn-intro-top img { width: clamp(90px, 12vw, 160px); height: auto; flex: none; } +.mn-iname { margin: 0 0 6px; font-size: clamp(2.2rem, 5vw, 4.4rem); line-height: 1; } +.mn-intro .mn-tag { text-align: left; margin-bottom: 10px; } +.mn-intro .mn-facts { justify-content: flex-start; } +.mn-steps { margin: 0; padding: 0; list-style: none; counter-reset: st; display: grid; gap: 12px; } +.mn-steps li { counter-increment: st; display: flex; gap: 16px; align-items: center; padding: 14px 20px; border-radius: 18px; background: var(--card); + font-size: clamp(1.1rem, 2vw, 1.7rem); font-weight: 600; animation: mn-in .4s ease both; animation-delay: calc(200ms + var(--i, 0) * 120ms); } +.mn-steps li::before { content: counter(st); flex: none; width: 1.8em; height: 1.8em; border-radius: 50%; display: grid; place-items: center; + background: var(--yellow); color: var(--navy); font-weight: 700; } +.mn-sets { display: grid; gap: 12px; } +.mn-set { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; } +.mn-set-l { color: var(--muted); font-weight: 600; min-width: 7em; } +.mn-opts { display: flex; flex-wrap: wrap; gap: 8px; } +.mn-opt { padding: 10px 20px; border: 0; border-radius: 999px; font: inherit; font-weight: 700; color: var(--ink); background: var(--card); + box-shadow: inset 0 0 0 2px var(--card-b); cursor: pointer; } +.mn-opt[aria-pressed=true] { background: var(--yellow); color: var(--navy); box-shadow: 0 4px 0 var(--yellow-d); } +.mn-opt:focus { outline: 3px solid #fff; outline-offset: 3px; } +.mn-hint { width: 100%; margin: 0; color: var(--muted); font-size: .95rem; } +.mn-actions { display: flex; gap: 16px; justify-content: flex-end; align-items: center; flex-wrap: wrap; } +.mn-actions .btn-xl { width: auto; } +.mn-back { background: var(--card); color: var(--ink); box-shadow: inset 0 0 0 2px var(--card-b); } +.mn-actions .btn:focus { outline: 4px solid #fff; outline-offset: 4px; } +.mn-busy .mn-main { opacity: .6; pointer-events: none; } +.mn-who { position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; color: var(--muted); margin: 0; } +@keyframes mn-in { from { opacity: 0; transform: translateY(14px); } } +@media (max-width: 640px) { .mn-who { position: static; padding-bottom: 12px; } .mn-intro-top { flex-direction: column; text-align: center; } .mn-intro .mn-tag { text-align: center; } .mn-intro .mn-facts { justify-content: center; } } +@media (prefers-reduced-motion: reduce) { .mn-card, .mn-intro, .mn-steps li { animation: none; } } diff --git a/party/templates/party/host_new.html b/party/templates/party/host_new.html index a528660..c65c6b8 100644 --- a/party/templates/party/host_new.html +++ b/party/templates/party/host_new.html @@ -1,29 +1,25 @@ {% extends "party/base.html" %} -{% block title %}Weirsøe Party Protocol{% endblock %} +{% load static %} +{% block body_class %}page-menu{% endblock %} {% block content %} -
-

Weirsøe Party Protocol

-
Fup og Fakta
-
{% csrf_token %} -
- Spørgsmål til Fup og Fakta - - - -
- Flere valg… - -

Arbejdsspørgsmål: e-faktura, IT-sikkerhed og kategorier med pakken "arbejde".

- -

Slang, memes og dansk pop med zoomer-speaker. Narret = tag en tår, finalen er en shot.

-
-
+ +{# menu.js sender denne formular med spillet og dets indstillinger #} +{% csrf_token %}
+
+ +{% if username %}

Logget ind som {{ username }}

{% endif %} +{% endblock %} +{% block scripts %} +{{ assets|json_script:"wpp-assets" }} +{{ games|json_script:"wpp-games" }} + + {% endblock %} diff --git a/party/tests/test_games.py b/party/tests/test_games.py index 46a21de..d20e1af 100644 --- a/party/tests/test_games.py +++ b/party/tests/test_games.py @@ -112,3 +112,34 @@ class StateNamesTheGameTests(TransactionTestCase): await phone.disconnect() await host.disconnect() room.close() + + +@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None) +class MenuViewTests(TestCase): + def test_menu_page_has_the_register_and_menu_js(self): + page = self.client.get("/host/", **HOST) + self.assertEqual(page.status_code, 200) + self.assertEqual(page.context["games"], games.menu_data()) + html = page.content.decode() + self.assertIn('id="wpp-games"', html) + self.assertIn("party/menu", html) + self.assertIn('id="mn-form"', html) + + def test_intro_is_the_fixed_line(self): + said = [] + + async def fake_synth(text, voice=None): + said.append((text, voice)) + return {"url": "/tts/x.mp3", "ms": 1234} + + with mock.patch("party.tts.synth", side_effect=fake_synth): + r = self.client.get("/host/intro/tegn", **HOST) + self.assertEqual(r.json(), {"url": "/tts/x.mp3", "ms": 1234}) + self.assertEqual(said, [(games.GAMES["tegn"].intro(), None)]) # standardstemmen, som tts_warm + + def test_intro_without_voice_unknown_game_and_no_host(self): + with mock.patch("party.tts.enabled", return_value=False), \ + mock.patch("party.tts._fetch", side_effect=AssertionError("netværk")): + self.assertEqual(self.client.get("/host/intro/fof", **HOST).json(), {}) + self.assertEqual(self.client.get("/host/intro/skak", **HOST).status_code, 404) + self.assertEqual(self.client.get("/host/intro/fof").status_code, 403) diff --git a/party/tests/test_tegn.py b/party/tests/test_tegn.py index 10607b1..973ee5b 100644 --- a/party/tests/test_tegn.py +++ b/party/tests/test_tegn.py @@ -2,6 +2,7 @@ import asyncio import random +from pathlib import Path from unittest import mock from channels.testing.websocket import WebsocketCommunicator @@ -209,6 +210,8 @@ class TegnViewTests(TestCase): afkrydse, og Enter/OK på den fokuserede "Tegn og Gæt" sendte formularen med game=fof.""" page = self.client.get("/host/", headers={"x-authentik-username": "agw"}, REMOTE_ADDR="127.0.0.1") html = page.content.decode() - self.assertNotRegex(html, r']*name="game"') - for key in ("fof", "tegn"): + menu = (Path(__file__).resolve().parents[1] / "static" / "party" / "menu.js").read_text(encoding="utf-8") + self.assertNotRegex(html + menu, r'radio') # menuen vælger med knapper, også spillets indstillinger + self.assertNotRegex(html, r']*type="hidden")[^>]*name="game"') + for key in ("fof", "tegn"): # uden JavaScript: én startknap pr. spil self.assertRegex(html, rf'