fix: reveal grid solutions when abandoning game

This commit is contained in:
thibaud-leclere 2026-04-11 10:58:33 +02:00
parent 9dcf43052d
commit 6e83355231
7 changed files with 97 additions and 35 deletions

View file

@ -1,8 +1,7 @@
import React from 'react';
import GameRow from './GameRow';
import ActorPopover from './ActorPopover';
export default function GameGrid({ grid, width, middle }) {
export default function GameGrid({ grid, width, middle, revealed = false }) {
return (
<div className="game-grid-scroll">
<table id="actors">
@ -34,6 +33,7 @@ export default function GameGrid({ grid, width, middle }) {
totalWidth={width}
hintType={row.hintType}
hintText={row.hintText}
revealed={revealed}
/>
);
})}

View file

@ -6,7 +6,7 @@ function isLetter(ch) {
return /[a-zA-Z]/.test(ch);
}
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText, revealed = false }) {
const inputRefs = useRef([]);
const letters = actorName.split('');
@ -57,6 +57,8 @@ export default function GameRow({ actorName, pos, colStart, totalWidth, hintType
inputRef={setInputRef(charIndex)}
onNext={() => focusNextInput(charIndex, 1)}
onPrev={() => focusNextInput(charIndex, -1)}
value={revealed ? ch.toUpperCase() : undefined}
disabled={revealed}
/>
);
})}

View file

@ -1,7 +1,11 @@
import React, { useRef, useCallback } from 'react';
import React, { useCallback } from 'react';
export default function LetterInput({ highlighted, onNext, onPrev, inputRef }) {
export default function LetterInput({ highlighted, onNext, onPrev, inputRef, value, disabled = false }) {
const handleKeyUp = useCallback((e) => {
if (disabled || value !== undefined) {
return;
}
if (e.key === 'Backspace') {
e.target.value = '';
onPrev?.();
@ -9,7 +13,7 @@ export default function LetterInput({ highlighted, onNext, onPrev, inputRef }) {
e.target.value = e.key.toUpperCase();
onNext?.();
}
}, [onNext, onPrev]);
}, [disabled, onNext, onPrev, value]);
return (
<td>
@ -18,6 +22,9 @@ export default function LetterInput({ highlighted, onNext, onPrev, inputRef }) {
type="text"
maxLength={1}
className={`letter-input${highlighted ? ' letter-highlighted' : ''}`}
value={value}
disabled={disabled}
readOnly={value !== undefined}
onKeyUp={handleKeyUp}
autoComplete="off"
/>

View file

@ -79,6 +79,12 @@ body {
box-shadow: 0 0 0 3px rgba(234, 88, 12, 0.15);
}
.letter-input:disabled {
opacity: 1;
cursor: default;
-webkit-text-fill-color: currentColor;
}
.letter-highlighted {
background-color: var(--orange-light);
border-color: var(--orange);
@ -612,6 +618,22 @@ body {
justify-content: flex-start;
}
.game-result-banner {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 12px 16px;
border: 1px solid var(--border-warm);
border-radius: var(--radius-md);
background: var(--surface-warm);
color: var(--text);
font-size: 14px;
font-weight: 600;
}
.btn-abandon {
padding: 7px 16px;
background: none;

View file

@ -23,6 +23,7 @@ class GameController extends AbstractController
GameRepository $gameRepository,
): Response {
$this->validateCsrfToken('game_start', $request);
$request->getSession()->remove('revealed_game_id');
/** @var User|null $user */
$user = $this->getUser();
@ -109,12 +110,13 @@ class GameController extends AbstractController
$game->abandon();
$em->flush();
$request->getSession()->set('revealed_game_id', $game->getId());
if (!$user) {
$request->getSession()->remove('current_game_id');
}
return $this->redirectToRoute('app_homepage');
return $this->redirectToRoute('app_homepage', ['revealed' => 1]);
}
private function validateCsrfToken(string $tokenId, Request $request): void

View file

@ -41,6 +41,27 @@ class HomepageController extends AbstractController
}
}
if ($game === null && $request->query->getBoolean('revealed')) {
$revealedGameId = $request->getSession()->get('revealed_game_id');
if (is_int($revealedGameId) || ctype_digit((string) $revealedGameId)) {
$revealedGame = $gameRepository->find((int) $revealedGameId);
if (
$revealedGame instanceof Game
&& $revealedGame->getStatus() === Game::STATUS_ABANDONED
&& (
($user === null && $revealedGame->getUser() === null)
|| ($user !== null && $revealedGame->getUser() === $user)
)
) {
$game = $revealedGame;
} else {
$request->getSession()->remove('revealed_game_id');
}
} else {
$request->getSession()->remove('revealed_game_id');
}
}
if (!$game) {
return $this->render('homepage/index.html.twig', [
'game' => null,

View file

@ -4,43 +4,51 @@
{% if game %}
<div class="game-container">
<div class="game-actions">
<div class="abandon-wrapper">
<button type="button" class="btn btn-abandon" id="abandon-trigger">Abandonner</button>
<div class="abandon-popover" id="abandon-popover">
<p class="abandon-popover-text">Êtes-vous sûr de vouloir abandonner ?</p>
<div class="abandon-popover-actions">
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
<button type="submit" class="btn btn-abandon-confirm">Abandonner</button>
</form>
<button type="button" class="btn btn-abandon-cancel" id="abandon-cancel">Non, continuer</button>
{% if game.status == constant('App\\Entity\\Game::STATUS_IN_PROGRESS') %}
<div class="abandon-wrapper">
<button type="button" class="btn btn-abandon" id="abandon-trigger">Abandonner</button>
<div class="abandon-popover" id="abandon-popover">
<p class="abandon-popover-text">Êtes-vous sûr de vouloir abandonner ?</p>
<div class="abandon-popover-actions">
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
<button type="submit" class="btn btn-abandon-confirm">Abandonner</button>
</form>
<button type="button" class="btn btn-abandon-cancel" id="abandon-cancel">Non, continuer</button>
</div>
</div>
</div>
</div>
<script>
(function() {
var trigger = document.getElementById('abandon-trigger');
var popover = document.getElementById('abandon-popover');
var cancel = document.getElementById('abandon-cancel');
trigger.addEventListener('click', function() {
popover.classList.toggle('open');
});
cancel.addEventListener('click', function() {
popover.classList.remove('open');
});
document.addEventListener('click', function(e) {
if (!popover.contains(e.target) && e.target !== trigger) {
<script>
(function() {
var trigger = document.getElementById('abandon-trigger');
var popover = document.getElementById('abandon-popover');
var cancel = document.getElementById('abandon-cancel');
trigger.addEventListener('click', function() {
popover.classList.toggle('open');
});
cancel.addEventListener('click', function() {
popover.classList.remove('open');
}
});
})();
</script>
});
document.addEventListener('click', function(e) {
if (!popover.contains(e.target) && e.target !== trigger) {
popover.classList.remove('open');
}
});
})();
</script>
{% else %}
<div class="game-result-banner">
<span>Partie abandonnée, voici la solution.</span>
<a href="{{ path('app_homepage') }}" class="btn btn-primary">Nouvelle partie</a>
</div>
{% endif %}
</div>
<div {{ react_component('GameGrid', {
grid: grid,
width: width,
middle: middle,
revealed: game.status == constant('App\\Entity\\Game::STATUS_ABANDONED'),
}) }}></div>
<div class="game-footer">