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'