Files
ltbxd-actorle/docs/superpowers/plans/2026-03-28-auth-react-frontend.md
thibaud-leclere 8af386bd5c docs: add implementation plan for auth + React frontend
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:06:42 +01:00

1527 lines
38 KiB
Markdown

# 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:
```bash
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:install` auto-script from composer.json**
Edit `composer.json` — in the `"auto-scripts"` section, remove the `"importmap:install": "symfony-cmd"` line. The result should be:
```json
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
```
- [ ] **Step 3: Delete importmap.php**
```bash
rm importmap.php
```
- [ ] **Step 4: Install Vite bundle and UX React**
```bash
docker compose exec app composer require pentatrion/vite-bundle symfony/ux-react
```
This will create `config/packages/pentatrion_vite.yaml` automatically.
- [ ] **Step 5: Commit**
```bash
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:
```json
{
"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:
```js
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`:
```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:
```js
import './bootstrap.js';
import './styles/app.css';
```
- [ ] **Step 5: Delete assets/stimulus_bootstrap.js**
```bash
rm assets/stimulus_bootstrap.js
```
- [ ] **Step 6: Update assets/controllers.json**
Replace `assets/controllers.json` with:
```json
{
"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**
```bash
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:
```twig
<!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**
```bash
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**
```bash
mkdir -p docker/node
```
Create `docker/node/Dockerfile`:
```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:
```yaml
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:
```yaml
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:
```dockerfile
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:
```makefile
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**
```bash
docker compose up --build -d
```
Wait for all services to be healthy. Check that:
- `http://localhost` responds (FrankenPHP) — may show Twig errors until React components exist, that's expected
- `http://localhost:5173` responds (Vite dev server)
- [ ] **Step 8: Commit**
```bash
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
<?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
<?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**
```bash
docker compose exec app php bin/console doctrine:migrations:diff --no-interaction
```
- [ ] **Step 4: Run migration**
```bash
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
```
Expected: the `user` table is created in PostgreSQL.
- [ ] **Step 5: Commit**
```bash
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:
```yaml
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**
```bash
docker compose exec app php bin/console debug:config security
```
Expected: outputs the resolved security config without errors.
- [ ] **Step 3: Commit**
```bash
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
<?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**
```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_homepage` route name to HomepageController**
In `src/Controller/HomepageController.php`, update the Route attribute:
```php
#[Route('/', name: 'app_homepage')]
```
- [ ] **Step 4: Commit**
```bash
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
<?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
<?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**
```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**
```bash
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`:
```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**
```bash
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**
```bash
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:
```jsx
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:
```php
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:
```twig
{% 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**
```bash
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**
```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**
```bash
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**
```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**
```bash
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**
```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**
```bash
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:
```jsx
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**
```bash
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:
```css
#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**
```bash
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**
```bash
docker compose down && docker compose up --build -d
```
- [ ] **Step 2: Test full flow**
1. Open `http://localhost` — should redirect to `/login`
2. Click "Register" link — should go to `/register`
3. Create an account (email + password)
4. Log in with those credentials
5. Homepage shows the Actorle grid with interactive letter inputs
6. Type a letter in an input — cursor moves to next input
7. Press Backspace — cursor moves to previous input
8. Click the `?` button on a row — popover appears with actor name
9. Click outside popover — it closes
10. Go to `/logout` — redirected to `/login`
- [ ] **Step 3: Check browser console**
Open DevTools console. Expected: no JavaScript errors. Vite HMR should be connected.