[SPA] Player reconnect UX-state (lost connection banner + retry) #153

Merged
integrator-bot merged 2 commits from dev/issue-148-player-reconnect-ux into main 2026-03-01 11:24:15 +01:00
5 changed files with 41 additions and 10 deletions

View File

@@ -40,6 +40,8 @@ var sessionDetailInFlight=false;
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function hostShellRouteFromPath(pathname){var base="/lobby/ui/host";if(!pathname||pathname===base||pathname===base+"/"){return"";}if(pathname.indexOf(base+"/")!==0){return"";}return pathname.slice((base+"/").length);}
function syncHostShellRoute(){var route=hostShellRouteFromPath(window.location.pathname);if(!route){return;}if(currentSessionStatus&&currentSessionStatus!==route){document.getElementById("hostErrorHint").textContent="Deep-link route guard: omdirigeret ("+route+" → "+currentSessionStatus+")";}window.history.replaceState({},"","/lobby/ui/host");}
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
@@ -59,7 +61,7 @@ function updateCreateSessionState(){var btn=document.getElementById("createSessi
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}saveHostContext();return d;}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}syncHostShellRoute();updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}saveHostContext();return d;}
function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});}
function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});}
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
@@ -72,7 +74,7 @@ function nextRound(){return withHostActionLock(function(){return api("/lobby/ses
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});}
["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
updatePhaseStatus();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();
if(restoreHostContext()){updatePhaseStatus();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
updatePhaseStatus();syncHostShellRoute();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();
if(restoreHostContext()){updatePhaseStatus();syncHostShellRoute();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
</script>
</body></html>

View File

@@ -9,6 +9,9 @@
#joinStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#contextLockHint { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#phaseStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
</style>
@@ -38,6 +41,7 @@
<p id="playerLastRefreshStatus">Session-data ikke opdateret endnu.</p>
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
<p id="playerErrorHint">Ingen fejl.</p>
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
<pre id="out">Klar.</pre>
<script>
var availableAnswers=[];
@@ -53,6 +57,8 @@ var playerAutoRefreshTimer=null;
var sessionDetailInFlight=false;
var playerLastRefreshAtLabel="";
var playerLastRefreshFailed=false;
var connectionLost=false;
var connectionRetryInFlight=false;
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function pid(){return document.getElementById("playerId").value.trim();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
@@ -75,11 +81,13 @@ function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLast
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}
function stopPlayerAutoRefresh(reason){playerAutoRefreshEnabled=false;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);playerAutoRefreshTimer=null;}if(reason){var hint=document.getElementById("playerAutoRefreshHint");if(hint){hint.textContent=reason;}}updatePlayerAutoRefreshUi();savePlayerContext();}
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail().catch(function(){});},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();return;}if(playerAutoRefreshEnabled){stopPlayerAutoRefresh();return;}startPlayerAutoRefresh();}
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();}
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();return;}status.textContent="Klar til join.";updateContextLockState();}
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();}
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
function lieStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppLie",c,p,q].join(":");}
@@ -94,9 +102,10 @@ function updateGuessSubmitState(){var selected=document.getElementById("guessTex
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
updateJoinState();}
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}savePlayerContext();return d;}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}try{var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";throw err;});}
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
function retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
function submitLie(){if(lieSubmitted){return Promise.resolve({error:"lie_already_submitted_client"});}if(lieSubmitInFlight){return Promise.resolve({error:"lie_submit_in_flight"});}if(!hasSubmitContext()){updateLieSubmitState();return Promise.resolve({error:"missing_submit_context"});}var text=(document.getElementById("lieText").value||"").trim();if(!text){updateLieSubmitState();return Promise.resolve({error:"empty_lie_text"});}lieSubmitInFlight=true;updateLieSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/lies/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,text:text}).then(function(d){if(d&&d.lie&&d.lie.id){lieSubmitted=true;persistLieState(text,true);}return d;}).finally(function(){lieSubmitInFlight=false;updateLieSubmitState();});}
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
@@ -107,6 +116,7 @@ updateJoinState();
updatePlayerAutoRefreshUi();
updatePlayerLastRefreshStatus();
updateRoundContextHint();
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail();}else{savePlayerContext();}
updateConnectionBanner();
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();}
</script>
</body></html>

View File

@@ -866,6 +866,19 @@ class UiScreenTests(TestCase):
self.assertContains(response, "Round question-id er låst mens session-opdatering kører.")
self.assertContains(response, "Kategori er låst mens session-opdatering kører.")
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
self.assertContains(response, "hostShellRouteFromPath")
self.assertContains(response, "syncHostShellRoute")
self.assertContains(response, "Deep-link route guard: omdirigeret")
def test_host_screen_deeplink_requires_login(self):
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
self.assertEqual(response.status_code, 302)
def test_host_screen_deeplink_renders_for_logged_in_user(self):
self.client.login(username="host_ui", password="secret123")
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Host panel")
def test_player_screen_is_public(self):
response = self.client.get(reverse("lobby:player_screen"))
@@ -898,6 +911,11 @@ class UiScreenTests(TestCase):
self.assertContains(response, "Udfyld kode og nickname for at join.")
self.assertContains(response, "Afvent aktiv session-opdatering før join.")
self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin")
self.assertContains(response, "id=\"connectionBanner\"")
self.assertContains(response, "id=\"connectionRetryBtn\"")
self.assertContains(response, "retryConnection")
self.assertContains(response, "setConnectionLost")
self.assertContains(response, "connection_lost")
self.assertContains(response, "id=\"contextLockHint\"")
self.assertContains(response, "updateContextLockState")
self.assertContains(response, "Spillerkontekst er låst efter join.")

View File

@@ -5,7 +5,7 @@ from fupogfakta.models import Category
@login_required
def host_screen(request):
def host_screen(request, spa_path=None):
categories = Category.objects.filter(is_active=True).order_by("name")
return render(request, "lobby/host_screen.html", {"categories": categories})

View File

@@ -6,6 +6,7 @@ app_name = "lobby"
urlpatterns = [
path("ui/host", ui_views.host_screen, name="host_screen"),
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
path("ui/player", ui_views.player_screen, name="player_screen"),
path("sessions/create", views.create_session, name="create_session"),
path("sessions/join", views.join_session, name="join_session"),