# 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 {% block title %}Welcome!{% endblock %} {% block stylesheets %} {{ vite_entry_link_tags('app') }} {% endblock %} {% block javascripts %} {{ vite_entry_script_tags('app') }} {% endblock %} {% block body %}{% endblock %} ``` - [ ] **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 */ #[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 */ public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; return array_unique($roles); } /** @param list $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 */ 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 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 %}

Log in

{% if error %}
{{ error.messageKey|trans(error.messageData, 'security') }}
{% endif %}
{% 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 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 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 %}

Register

{{ form_start(registrationForm) }} {{ form_row(registrationForm.email) }} {{ form_row(registrationForm.plainPassword.first) }} {{ form_row(registrationForm.plainPassword.second) }} {{ form_end(registrationForm) }}
{% 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 ( {grid.map((row, 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 ( ); })} ))}
{isInRange ? name[charIndex].toUpperCase() : ''}
); } ``` - [ ] **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 ( ); } ``` - [ ] **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 ( <> {isOpen && (
{actorName}
)} ); } ``` - [ ] **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 ( {Array.from({ length: totalWidth + 1 }, (_, colIndex) => { const charIndex = colIndex - colStart; const isInRange = charIndex >= 0 && charIndex < letters.length; if (!isInRange) { return ; } return ( focusInput(charIndex + 1)} onPrev={() => focusInput(charIndex - 1)} /> ); })} ); } ``` - [ ] **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 ( {grid.map((row, rowIndex) => ( ))}
); } ``` - [ ] **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.