1527 lines
38 KiB
Markdown
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.
|