38 KiB
Auth + React Frontend Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add user authentication (login/register) and migrate the frontend from Asset Mapper to Vite + React via SymfonyUX, converting the Actorle game grid into interactive React components.
Architecture: Symfony handles routing, sessions, and auth via Twig pages. Interactive game components are React, mounted in Twig via {{ react_component() }}. Vite replaces Asset Mapper for JS bundling. A dedicated Node Docker service runs the Vite dev server.
Tech Stack: Symfony 8.0, FrankenPHP, PostgreSQL 16, Vite (pentatrion/vite-bundle), React 19 (symfony/ux-react), @floating-ui/react, Docker
File Structure
New Files
| File | Responsibility |
|---|---|
docker/node/Dockerfile |
Node 22 image for Vite dev server (dev) and asset build (prod) |
vite.config.js |
Vite configuration with React plugin and Symfony integration |
package.json |
npm dependencies (React, Vite, Floating UI) |
assets/bootstrap.js |
Stimulus app initialization (replaces stimulus_bootstrap.js) |
assets/react/controllers/GameGrid.jsx |
Root game grid component |
assets/react/controllers/GameRow.jsx |
Single actor row |
assets/react/controllers/LetterInput.jsx |
Single letter input with auto-focus |
assets/react/controllers/ActorPopover.jsx |
Actor info popover |
src/Entity/User.php |
User entity with email/password/roles |
src/Repository/UserRepository.php |
Doctrine repository for User |
src/Controller/SecurityController.php |
Login/logout routes |
src/Controller/RegistrationController.php |
Registration route |
src/Form/RegistrationType.php |
Registration form type |
templates/security/login.html.twig |
Login page |
templates/security/register.html.twig |
Registration page |
Modified Files
| File | Change |
|---|---|
composer.json |
Remove symfony/asset-mapper, add pentatrion/vite-bundle + symfony/ux-react |
config/packages/security.yaml |
Full auth config with form_login |
config/packages/asset_mapper.yaml |
Deleted |
importmap.php |
Deleted |
assets/stimulus_bootstrap.js |
Deleted (replaced by bootstrap.js) |
assets/app.js |
Adjusted imports for Vite |
assets/controllers.json |
Add ux-react controller |
templates/base.html.twig |
Vite tags instead of importmap |
templates/homepage/index.html.twig |
Replace HTML table with {{ react_component() }} |
src/Controller/HomepageController.php |
Pass grid data as JSON-serializable props |
docker-compose.override.yaml |
Add node service for dev |
docker-compose.yaml |
Add node build for prod |
docker/app/Dockerfile |
Prod stage copies built JS assets |
Makefile |
Add node/npm targets |
Task 1: Remove Asset Mapper and Install Vite + React (Composer)
Files:
-
Modify:
composer.json -
Delete:
importmap.php -
Delete:
config/packages/asset_mapper.yaml -
Step 1: Remove Asset Mapper package
Run inside the app container:
docker compose exec app composer remove symfony/asset-mapper
This removes the package from composer.json, deletes config/packages/asset_mapper.yaml, and updates the autoloader.
- Step 2: Remove the
importmap:installauto-script from composer.json
Edit composer.json — in the "auto-scripts" section, remove the "importmap:install": "symfony-cmd" line. The result should be:
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
- Step 3: Delete importmap.php
rm importmap.php
- Step 4: Install Vite bundle and UX React
docker compose exec app composer require pentatrion/vite-bundle symfony/ux-react
This will create config/packages/pentatrion_vite.yaml automatically.
- Step 5: Commit
git add -A
git commit -m "chore: remove asset-mapper, install vite-bundle and ux-react"
Task 2: Setup npm and Vite Configuration
Files:
-
Create:
package.json -
Create:
vite.config.js -
Modify:
assets/app.js -
Create:
assets/bootstrap.js -
Delete:
assets/stimulus_bootstrap.js -
Modify:
assets/controllers.json -
Step 1: Create package.json
Create package.json at project root:
{
"name": "ltbxd-actorle",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@floating-ui/react": "^0.27",
"@hotwired/stimulus": "^3.2",
"@hotwired/turbo": "^7.3",
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
"@symfony/ux-react": "file:vendor/symfony/ux-react/assets",
"react": "^19.0",
"react-dom": "^19.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3",
"vite": "^6.0"
}
}
- Step 2: Create vite.config.js
Create vite.config.js at project root:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import symfonyPlugin from 'vite-plugin-symfony';
export default defineConfig({
plugins: [
react(),
symfonyPlugin(),
],
build: {
rollupOptions: {
input: {
app: './assets/app.js',
},
},
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
origin: 'http://localhost:5173',
},
});
- Step 3: Create assets/bootstrap.js
Create assets/bootstrap.js:
import { startStimulusApp } from '@symfony/stimulus-bundle';
import { registerReactControllerComponents } from '@symfony/ux-react';
const app = startStimulusApp();
registerReactControllerComponents(require.context('./react/controllers', true, /\.(j|t)sx?$/));
- Step 4: Update assets/app.js
Replace the contents of assets/app.js with:
import './bootstrap.js';
import './styles/app.css';
- Step 5: Delete assets/stimulus_bootstrap.js
rm assets/stimulus_bootstrap.js
- Step 6: Update assets/controllers.json
Replace assets/controllers.json with:
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
},
"@symfony/ux-react": {
"react": {
"enabled": true,
"fetch": "eager"
}
}
},
"entrypoints": []
}
- Step 7: Commit
git add package.json vite.config.js assets/
git commit -m "chore: configure vite, react, and stimulus bootstrap"
Task 3: Update Twig Base Template for Vite
Files:
-
Modify:
templates/base.html.twig -
Step 1: Replace importmap with Vite tags
Replace templates/base.html.twig with:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{{ vite_entry_link_tags('app') }}
{% endblock %}
{% block javascripts %}
{{ vite_entry_script_tags('app') }}
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
- Step 2: Commit
git add templates/base.html.twig
git commit -m "chore: switch base template from importmap to vite"
Task 4: Docker Node Service
Files:
-
Create:
docker/node/Dockerfile -
Modify:
docker-compose.override.yaml -
Modify:
docker-compose.yaml -
Modify:
docker/app/Dockerfile -
Modify:
Makefile -
Step 1: Create docker/node/Dockerfile
mkdir -p docker/node
Create docker/node/Dockerfile:
FROM node:22-alpine AS base
WORKDIR /app
###
# Dev stage
###
FROM base AS dev
# Dependencies are mounted via volume, install at startup
CMD ["sh", "-c", "npm install && npx vite"]
###
# Build stage (used by app prod image)
###
FROM base AS build
COPY package.json package-lock.json* ./
RUN npm install
COPY assets/ ./assets/
COPY vite.config.js ./
RUN npm run build
- Step 2: Update docker-compose.override.yaml
Replace docker-compose.override.yaml with:
services:
app:
build:
context: .
dockerfile: docker/app/Dockerfile
target: dev
environment:
APP_ENV: dev
volumes:
- .:/app
- vendor:/app/vendor
ports:
- "80:80"
database:
ports:
- "0.0.0.0:5432:5432"
node:
build:
context: .
dockerfile: docker/node/Dockerfile
target: dev
volumes:
- .:/app
- node_modules:/app/node_modules
ports:
- "5173:5173"
volumes:
vendor:
node_modules:
- Step 3: Update docker-compose.yaml prod to build JS assets
Replace docker-compose.yaml with:
services:
app:
build:
context: .
dockerfile: docker/app/Dockerfile
target: prod
additional_contexts:
node-build: docker/node
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- caddy_data:/data
- caddy_config:/config
depends_on:
database:
condition: service_healthy
database:
build:
context: docker/database
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/database:latest
healthcheck:
test: ["CMD", "pg_isready", "-d", "app", "-U", "app"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql/data:rw
volumes:
database_data:
caddy_data:
caddy_config:
- Step 4: Update docker/app/Dockerfile to copy built assets in prod
Replace docker/app/Dockerfile with:
FROM dunglas/frankenphp:php8.4-alpine AS base
RUN install-php-extensions \
intl \
opcache \
pdo_pgsql \
zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
###
# Dev stage
###
FROM base AS dev
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload
ENV APP_ENV=dev \
SERVER_NAME=":80" \
POSTGRES_HOST=database \
POSTGRES_PORT=5432 \
POSTGRES_VERSION=16 \
POSTGRES_DB=app \
POSTGRES_USER=app \
POSTGRES_PASSWORD=pwd
###
# Node build stage (for prod assets)
###
FROM node:22-alpine AS node-build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY assets/ ./assets/
COPY vite.config.js ./
RUN npm run build
###
# Prod stage
###
FROM base AS prod
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
# Copy Vite-built assets
COPY --from=node-build /app/public/build /app/public/build
RUN APP_ENV=prod composer dump-autoload --classmap-authoritative \
&& APP_ENV=prod composer run-script post-install-cmd \
&& chown -R www-data:www-data var/
ENV APP_ENV=prod \
SERVER_NAME=localhost \
POSTGRES_HOST=database \
POSTGRES_PORT=5432 \
POSTGRES_VERSION=16 \
POSTGRES_DB=app \
POSTGRES_USER=app \
POSTGRES_PASSWORD=pwd
- Step 5: Add npm targets to Makefile
Append these targets to the Makefile, before the help target:
node\:shell: ## Ouvre un shell dans le conteneur node
docker compose exec node sh
node\:install: ## Installe les dépendances npm
docker compose exec node npm install
node\:build: ## Build les assets pour la production
docker compose exec node npm run build
- Step 6: Add .gitignore entries
Append to .gitignore:
/node_modules/
/public/build/
- Step 7: Verify dev environment starts
docker compose up --build -d
Wait for all services to be healthy. Check that:
-
http://localhostresponds (FrankenPHP) — may show Twig errors until React components exist, that's expected -
http://localhost:5173responds (Vite dev server) -
Step 8: Commit
git add docker/ docker-compose.yaml docker-compose.override.yaml Makefile .gitignore
git commit -m "chore: add node docker service for vite dev server and prod build"
Task 5: User Entity and Migration
Files:
-
Create:
src/Entity/User.php -
Create:
src/Repository/UserRepository.php -
Create: migration file (auto-generated)
-
Step 1: Create src/Entity/User.php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private ?string $email = null;
/** @var list<string> */
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/** @param list<string> $roles */
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
}
}
- Step 2: Create src/Repository/UserRepository.php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}
- Step 3: Generate migration
docker compose exec app php bin/console doctrine:migrations:diff --no-interaction
- Step 4: Run migration
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
Expected: the user table is created in PostgreSQL.
- Step 5: Commit
git add src/Entity/User.php src/Repository/UserRepository.php migrations/
git commit -m "feat: add User entity with email/password/roles"
Task 6: Security Configuration
Files:
-
Modify:
config/packages/security.yaml -
Step 1: Update security.yaml
Replace config/packages/security.yaml with:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
default_target_path: /
logout:
path: app_logout
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
when@test:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4
time_cost: 3
memory_cost: 10
- Step 2: Verify config is valid
docker compose exec app php bin/console debug:config security
Expected: outputs the resolved security config without errors.
- Step 3: Commit
git add config/packages/security.yaml
git commit -m "feat: configure security with form_login and access control"
Task 7: Login Controller and Template
Files:
-
Create:
src/Controller/SecurityController.php -
Create:
templates/security/login.html.twig -
Step 1: Create src/Controller/SecurityController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('app_homepage');
}
return $this->render('security/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}
- Step 2: Create templates/security/login.html.twig
{% extends 'base.html.twig' %}
{% block title %}Log in{% endblock %}
{% block body %}
<div class="auth-container">
<h1>Log in</h1>
{% if error %}
<div class="auth-error">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
<form method="post">
<label for="inputEmail">Email</label>
<input type="email" value="{{ last_username }}" name="_username" id="inputEmail" autocomplete="email" required autofocus>
<label for="inputPassword">Password</label>
<input type="password" name="_password" id="inputPassword" autocomplete="current-password" required>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button type="submit">Sign in</button>
</form>
<p class="auth-link">No account yet? <a href="{{ path('app_register') }}">Register</a></p>
</div>
{% endblock %}
- Step 3: Add the
app_homepageroute name to HomepageController
In src/Controller/HomepageController.php, update the Route attribute:
#[Route('/', name: 'app_homepage')]
- Step 4: Commit
git add src/Controller/SecurityController.php templates/security/login.html.twig src/Controller/HomepageController.php
git commit -m "feat: add login page with SecurityController"
Task 8: Registration Controller, Form, and Template
Files:
-
Create:
src/Form/RegistrationType.php -
Create:
src/Controller/RegistrationController.php -
Create:
templates/security/register.html.twig -
Step 1: Create src/Form/RegistrationType.php
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class RegistrationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class)
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Confirm password'],
'constraints' => [
new NotBlank(message: 'Please enter a password.'),
new Length(
min: 6,
minMessage: 'Your password should be at least {{ limit }} characters.',
max: 4096,
),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
- Step 2: Create src/Controller/RegistrationController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Form\RegistrationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $passwordHasher,
EntityManagerInterface $entityManager,
): Response {
if ($this->getUser()) {
return $this->redirectToRoute('app_homepage');
}
$user = new User();
$form = $this->createForm(RegistrationType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->setPassword(
$passwordHasher->hashPassword($user, $form->get('plainPassword')->getData())
);
$entityManager->persist($user);
$entityManager->flush();
return $this->redirectToRoute('app_login');
}
return $this->render('security/register.html.twig', [
'registrationForm' => $form,
]);
}
}
- Step 3: Create templates/security/register.html.twig
{% extends 'base.html.twig' %}
{% block title %}Register{% endblock %}
{% block body %}
<div class="auth-container">
<h1>Register</h1>
{{ form_start(registrationForm) }}
{{ form_row(registrationForm.email) }}
{{ form_row(registrationForm.plainPassword.first) }}
{{ form_row(registrationForm.plainPassword.second) }}
<button type="submit">Create account</button>
{{ form_end(registrationForm) }}
<p class="auth-link">Already have an account? <a href="{{ path('app_login') }}">Log in</a></p>
</div>
{% endblock %}
- Step 4: Commit
git add src/Form/RegistrationType.php src/Controller/RegistrationController.php templates/security/register.html.twig
git commit -m "feat: add registration page with form validation"
Task 9: Auth Styling
Files:
-
Modify:
assets/styles/app.css -
Step 1: Add auth styles to app.css
Append to assets/styles/app.css:
.auth-container {
max-width: 400px;
margin: 80px auto;
padding: 32px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.auth-container h1 {
margin: 0 0 24px;
text-align: center;
}
.auth-container label {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
.auth-container input[type="email"],
.auth-container input[type="password"] {
width: 100%;
padding: 8px 12px;
margin-bottom: 16px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
.auth-container button[type="submit"] {
width: 100%;
padding: 10px;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.auth-container button[type="submit"]:hover {
background: #1d4ed8;
}
.auth-error {
background: #fef2f2;
color: #dc2626;
padding: 12px;
border-radius: 4px;
margin-bottom: 16px;
}
.auth-link {
text-align: center;
margin-top: 16px;
}
.auth-link a {
color: #2563eb;
}
- Step 2: Commit
git add assets/styles/app.css
git commit -m "style: add auth page styling"
Task 10: Smoke Test Auth Flow
No new files — manual verification.
- Step 1: Rebuild and start the dev environment
docker compose up --build -d
- Step 2: Verify unauthenticated redirect
Open http://localhost in a browser. Expected: redirect to /login.
- Step 3: Test registration
Go to /register, fill in email + password, submit. Expected: redirect to /login.
- Step 4: Test login
Log in with the credentials just created. Expected: redirect to / (homepage). The page may be broken (React components not yet built) — that's expected at this stage. The important thing is the redirect works and you're authenticated.
- Step 5: Test logout
Go to /logout. Expected: redirect to /login.
Task 11: GameGrid React Component (Placeholder)
Files:
-
Create:
assets/react/controllers/GameGrid.jsx -
Modify:
templates/homepage/index.html.twig -
Modify:
src/Controller/HomepageController.php -
Step 1: Create assets/react/controllers/GameGrid.jsx
Start with a simple placeholder that renders the grid data as a static table, confirming React mounting works:
import React from 'react';
export default function GameGrid({ grid, width, middle }) {
return (
<table id="actors">
<tbody>
{grid.map((row, rowIndex) => (
<tr key={rowIndex}>
{Array.from({ length: width + 1 }, (_, colIndex) => {
const start = middle - row.pos;
const charIndex = colIndex - start;
const name = row.actorName;
const isInRange = charIndex >= 0 && charIndex < name.length;
const isHighlighted = charIndex === row.pos;
return (
<td key={colIndex} style={isHighlighted ? { color: 'red' } : undefined}>
{isInRange ? name[charIndex].toUpperCase() : ''}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
- Step 2: Update HomepageController to pass JSON-serializable props
Replace the return statement in src/Controller/HomepageController.php's index() method. Change the entire method body to:
public function index(SerializerInterface $serializer): Response
{
// Final actor to be guessed
$mainActor = $this->actorRepository->findOneRandom(4);
// Actors for the grid
$actors = [];
$leftSize = 0;
$rightSize = 0;
foreach (str_split(strtolower($mainActor->getName())) as $char) {
if (!preg_match('/[a-z]/', $char)) {
continue;
}
$tryFindActor = 0;
do {
$actor = $this->actorRepository->findOneRandom(4, $char);
++$tryFindActor;
} while (
$actor === $mainActor
|| in_array($actor, array_map(fn ($actorMap) => $actorMap['actor'], $actors))
|| $tryFindActor < 5
);
$actorData = [
'actor' => $actor,
'pos' => strpos($actor->getName(), $char),
];
if ($leftSize < $actorData['pos']) {
$leftSize = $actorData['pos'];
}
$rightSizeActor = strlen($actor->getName()) - $actorData['pos'] - 1;
if ($rightSize < $rightSizeActor) {
$rightSize = $rightSizeActor;
}
$actors[] = $actorData;
}
// Predict grid size
$width = $rightSize + $leftSize + 1;
$middle = $leftSize;
// Build JSON-serializable grid for React
$grid = array_map(fn (array $actorData) => [
'actorName' => $actorData['actor']->getName(),
'actorId' => $actorData['actor']->getId(),
'pos' => $actorData['pos'],
], $actors);
return $this->render('homepage/index.html.twig', [
'grid' => $grid,
'width' => $width,
'middle' => $middle,
]);
}
- Step 3: Update templates/homepage/index.html.twig
Replace templates/homepage/index.html.twig with:
{% extends 'base.html.twig' %}
{% block body %}
{{ react_component('GameGrid', {
grid: grid,
width: width,
middle: middle,
}) }}
{% endblock %}
- Step 4: Verify the React component renders
Rebuild and open http://localhost (after logging in). Expected: the same grid as before, but now rendered by React. Check the browser console for any errors.
- Step 5: Commit
git add assets/react/controllers/GameGrid.jsx templates/homepage/index.html.twig src/Controller/HomepageController.php
git commit -m "feat: render game grid as React component via SymfonyUX"
Task 12: LetterInput Component
Files:
-
Create:
assets/react/controllers/LetterInput.jsx -
Step 1: Create LetterInput.jsx
import React, { useRef, useCallback } from 'react';
export default function LetterInput({ highlighted, onNext, onPrev, inputRef }) {
const handleKeyUp = useCallback((e) => {
if (e.key === 'Backspace') {
e.target.value = '';
onPrev?.();
} else if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) {
e.target.value = e.key.toUpperCase();
onNext?.();
}
}, [onNext, onPrev]);
return (
<td>
<input
ref={inputRef}
type="text"
maxLength={1}
className={`letter-input${highlighted ? ' letter-highlighted' : ''}`}
onKeyUp={handleKeyUp}
autoComplete="off"
/>
</td>
);
}
- Step 2: Commit
git add assets/react/controllers/LetterInput.jsx
git commit -m "feat: add LetterInput component with auto-focus navigation"
Task 13: ActorPopover Component
Files:
-
Create:
assets/react/controllers/ActorPopover.jsx -
Step 1: Create ActorPopover.jsx
import React, { useState } from 'react';
import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react';
export default function ActorPopover({ actorName }) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(8), flip(), shift()],
placement: 'top',
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
return (
<>
<button
ref={refs.setReference}
{...getReferenceProps()}
className="popover-trigger"
type="button"
>
?
</button>
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="actor-popover"
>
<strong>{actorName}</strong>
</div>
)}
</>
);
}
- Step 2: Commit
git add assets/react/controllers/ActorPopover.jsx
git commit -m "feat: add ActorPopover component with floating-ui"
Task 14: GameRow Component
Files:
-
Create:
assets/react/controllers/GameRow.jsx -
Step 1: Create GameRow.jsx
import React, { useRef, useCallback } from 'react';
import LetterInput from './LetterInput';
import ActorPopover from './ActorPopover';
export default function GameRow({ actorName, pos, colStart, totalWidth }) {
const inputRefs = useRef([]);
const setInputRef = useCallback((index) => (el) => {
inputRefs.current[index] = el;
}, []);
const focusInput = useCallback((index) => {
inputRefs.current[index]?.focus();
}, []);
const letters = actorName.split('');
return (
<tr>
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
const charIndex = colIndex - colStart;
const isInRange = charIndex >= 0 && charIndex < letters.length;
if (!isInRange) {
return <td key={colIndex} />;
}
return (
<LetterInput
key={colIndex}
highlighted={charIndex === pos}
inputRef={setInputRef(charIndex)}
onNext={() => focusInput(charIndex + 1)}
onPrev={() => focusInput(charIndex - 1)}
/>
);
})}
<td>
<ActorPopover actorName={actorName} />
</td>
</tr>
);
}
- Step 2: Commit
git add assets/react/controllers/GameRow.jsx
git commit -m "feat: add GameRow component composing LetterInput and ActorPopover"
Task 15: Integrate GameRow into GameGrid
Files:
-
Modify:
assets/react/controllers/GameGrid.jsx -
Step 1: Update GameGrid to use GameRow
Replace assets/react/controllers/GameGrid.jsx with:
import React from 'react';
import GameRow from './GameRow';
export default function GameGrid({ grid, width, middle }) {
return (
<table id="actors">
<tbody>
{grid.map((row, rowIndex) => (
<GameRow
key={rowIndex}
actorName={row.actorName}
pos={row.pos}
colStart={middle - row.pos}
totalWidth={width}
/>
))}
</tbody>
</table>
);
}
- Step 2: Commit
git add assets/react/controllers/GameGrid.jsx
git commit -m "feat: integrate GameRow into GameGrid"
Task 16: Game Grid Styling
Files:
-
Modify:
assets/styles/app.css -
Step 1: Add game grid styles
Replace the existing #actors td rule in assets/styles/app.css with:
#actors {
border-collapse: collapse;
margin: 40px auto;
}
#actors td {
width: 32px;
height: 32px;
text-align: center;
vertical-align: middle;
}
.letter-input {
width: 32px;
height: 32px;
text-align: center;
font-size: 16px;
font-weight: bold;
border: 2px solid #d1d5db;
border-radius: 4px;
text-transform: uppercase;
padding: 0;
box-sizing: border-box;
}
.letter-input:focus {
outline: none;
border-color: #2563eb;
}
.letter-highlighted {
background-color: #fecaca;
border-color: #dc2626;
}
.popover-trigger {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid #d1d5db;
background: white;
cursor: pointer;
font-size: 14px;
color: #6b7280;
}
.popover-trigger:hover {
background: #f3f4f6;
}
.actor-popover {
background: white;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
font-size: 14px;
}
- Step 2: Commit
git add assets/styles/app.css
git commit -m "style: add game grid and popover styling"
Task 17: End-to-End Smoke Test
No new files — manual verification.
- Step 1: Rebuild everything
docker compose down && docker compose up --build -d
- Step 2: Test full flow
- Open
http://localhost— should redirect to/login - Click "Register" link — should go to
/register - Create an account (email + password)
- Log in with those credentials
- Homepage shows the Actorle grid with interactive letter inputs
- Type a letter in an input — cursor moves to next input
- Press Backspace — cursor moves to previous input
- Click the
?button on a row — popover appears with actor name - Click outside popover — it closes
- Go to
/logout— redirected to/login
- Step 3: Check browser console
Open DevTools console. Expected: no JavaScript errors. Vite HMR should be connected.