Compare commits

..

23 Commits

Author SHA1 Message Date
thibaud-leclere
cb0ea949f6 chore(docker): add image for node
Some checks failed
Build and Push Docker Images / Build app image (push) Failing after 10s
Build and Push Docker Images / Build database image (push) Successful in 9s
2026-03-28 13:54:43 +01:00
thibaud-leclere
7abca03122 fix: drop react-swc plugin for esbuild JSX and add vite-bundle routes
Use esbuild's built-in JSX transform instead of @vitejs/plugin-react-swc
to fix the React Fast Refresh preamble error caused by eager glob imports.
Add missing pentatrion_vite routes to fix web debug toolbar 500 errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:51:54 +01:00
thibaud-leclere
a6064c2bdb fix: switch to @vitejs/plugin-react-swc and manual component registration
- Replace @vitejs/plugin-react with @vitejs/plugin-react-swc to fix
  React Refresh preamble detection error in cross-origin Docker setup
- Register React components manually via window.resolveReactComponent
  since ux-react's registerReactControllerComponents uses Webpack's
  require.context API which is incompatible with Vite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:40:16 +01:00
thibaud-leclere
04301642bc fix: add CORS and HMR config to vite for cross-origin Docker setup
The app is served on port 80 while Vite runs on 5173. The React
plugin needs proper CORS and HMR host config to inject its preamble.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:36:57 +01:00
thibaud-leclere
783a8492e9 fix: use import.meta.glob instead of require.context for Vite compatibility
require.context is Webpack-only. Vite uses import.meta.glob.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:35:57 +01:00
thibaud-leclere
d590120306 fix: register ViteBundle, add config, share vendor volume with node, add vite-plugin-symfony
- Add PentatrionViteBundle to bundles.php (Flex recipe didn't auto-register)
- Create pentatrion_vite.yaml config
- Share vendor named volume with node container (needed for file: npm deps)
- Add vite-plugin-symfony to package.json devDependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:26:47 +01:00
thibaud-leclere
deb826401d style: add game grid and popover styling 2026-03-28 13:19:53 +01:00
thibaud-leclere
6290cef3fe feat: integrate GameRow into GameGrid 2026-03-28 13:19:44 +01:00
thibaud-leclere
29667f0b1e feat: add GameRow component composing LetterInput and ActorPopover 2026-03-28 13:19:37 +01:00
thibaud-leclere
ac5bd38954 feat: add ActorPopover component with floating-ui 2026-03-28 13:19:29 +01:00
thibaud-leclere
ad014c2547 feat: add LetterInput component with auto-focus navigation 2026-03-28 13:19:21 +01:00
thibaud-leclere
748b1c7a08 feat: render game grid as React component via SymfonyUX 2026-03-28 13:19:13 +01:00
thibaud-leclere
1640d8d9d9 style: add auth page styling 2026-03-28 13:17:19 +01:00
thibaud-leclere
6d40c4ce08 feat: add registration page with form validation 2026-03-28 13:17:07 +01:00
thibaud-leclere
64949d2ec2 feat: add login page with SecurityController 2026-03-28 13:16:50 +01:00
thibaud-leclere
1720246382 feat: configure security with form_login and access control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:15:43 +01:00
thibaud-leclere
376a01bff5 feat: add User entity with email/password/roles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:15:40 +01:00
thibaud-leclere
d175202163 chore: add node docker service for vite dev server and prod build 2026-03-28 13:14:09 +01:00
thibaud-leclere
c3dab636b1 chore: switch base template from importmap to vite 2026-03-28 13:11:57 +01:00
thibaud-leclere
f7a9be6a38 chore: configure vite, react, and stimulus bootstrap 2026-03-28 13:11:41 +01:00
thibaud-leclere
bd3996f4a9 chore: remove asset-mapper, install vite-bundle and ux-react 2026-03-28 13:11:09 +01:00
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
thibaud-leclere
e376a97dad docs: add design spec for auth + React frontend
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:02:09 +01:00
45 changed files with 7458 additions and 287 deletions

9
.gitignore vendored
View File

@@ -1,4 +1,3 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
@@ -13,10 +12,6 @@
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
/.idea/
/node_modules/
/public/build/

View File

@@ -55,6 +55,15 @@ symfony\:cache-clear: ## Vide le cache Symfony
test: ## Lance les tests PHPUnit
docker compose exec app php bin/phpunit
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
help: ## Affiche cette aide
@grep -E '^[a-zA-Z_\\:-]+:.*## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = "## "} {gsub(/\\:/, ":", $$1); sub(/:[^:]*$$/, "", $$1); printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}'

View File

@@ -1,10 +1,2 @@
import './stimulus_bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './bootstrap.js';
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

19
assets/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,19 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// Register React components for {{ react_component() }} Twig function.
// We register them manually because @symfony/ux-react's registerReactControllerComponents
// expects Webpack's require.context API, which is not available in Vite.
const reactControllers = import.meta.glob('./react/controllers/**/*.jsx', { eager: true });
window.resolveReactComponent = (name) => {
const key = `./react/controllers/${name}.jsx`;
const module = reactControllers[key];
if (!module) {
const available = Object.keys(reactControllers)
.map(k => k.replace('./react/controllers/', '').replace('.jsx', ''));
throw new Error(`React controller "${name}" does not exist. Possible values: ${available.join(', ')}`);
}
return module.default;
};

View File

@@ -9,6 +9,12 @@
"enabled": false,
"fetch": "eager"
}
},
"@symfony/ux-react": {
"react": {
"enabled": true,
"fetch": "eager"
}
}
},
"entrypoints": []

View File

@@ -0,0 +1,40 @@
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>
)}
</>
);
}

View File

@@ -0,0 +1,20 @@
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>
);
}

View File

@@ -0,0 +1,43 @@
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>
);
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function (props) {
return <div>Hello {props.fullName}</div>;
}

View File

@@ -0,0 +1,26 @@
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>
);
}

View File

@@ -1,5 +0,0 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);

View File

@@ -3,7 +3,125 @@ body {
font-family: 'Noto Sans', sans-serif;
}
#actors td {
width: 16px;
height: 16px;
#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;
}
.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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

22
assets/vendor/installed.php vendored Normal file
View File

@@ -0,0 +1,22 @@
<?php return array (
'@hotwired/stimulus' =>
array (
'version' => '3.2.2',
'dependencies' =>
array (
),
'extraFiles' =>
array (
),
),
'@hotwired/turbo' =>
array (
'version' => '7.3.0',
'dependencies' =>
array (
),
'extraFiles' =>
array (
),
),
);

View File

@@ -10,10 +10,10 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"pentatrion/vite-bundle": "^8.2",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*",
"symfony/asset-mapper": "8.0.*",
"symfony/cache": "8.0.*",
"symfony/console": "8.0.*",
"symfony/doctrine-messenger": "8.0.*",
@@ -38,6 +38,7 @@
"symfony/string": "8.0.*",
"symfony/translation": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/ux-react": "^2.34",
"symfony/ux-turbo": "^2.32",
"symfony/validator": "8.0.*",
"symfony/web-link": "8.0.*",
@@ -79,8 +80,7 @@
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"

301
composer.lock generated
View File

@@ -4,85 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c7440a2e3322d18c43b59a7aa236524b",
"content-hash": "b7a68491821af0428e8aaf05764167ac",
"packages": [
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "doctrine/collections",
"version": "2.5.1",
@@ -1366,6 +1289,67 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "pentatrion/vite-bundle",
"version": "v8.2.4",
"source": {
"type": "git",
"url": "https://github.com/lhapaipai/vite-bundle.git",
"reference": "80a5391af3a924597d65e7c81a4b28d212fea5fe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lhapaipai/vite-bundle/zipball/80a5391af3a924597d65e7c81a4b28d212fea5fe",
"reference": "80a5391af3a924597d65e7c81a4b28d212fea5fe",
"shasum": ""
},
"require": {
"php": "^8.0",
"symfony/asset": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/framework-bundle": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/http-client": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/http-kernel": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/twig-bundle": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.9",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/phpunit": "^9.5",
"symfony/phpunit-bridge": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/web-link": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Pentatrion\\ViteBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Hugues Tavernier",
"email": "hugues.tavernier@protonmail.com"
}
],
"description": "Vite integration for your Symfony app",
"keywords": [
"bundle",
"symfony",
"vite",
"vitejs"
],
"support": {
"issues": "https://github.com/lhapaipai/vite-bundle/issues",
"source": "https://github.com/lhapaipai/vite-bundle/tree/v8.2.4"
},
"time": "2026-03-22T11:41:50+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -1965,87 +1949,6 @@
],
"time": "2025-08-04T07:36:47+00:00"
},
{
"name": "symfony/asset-mapper",
"version": "v8.0.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/asset-mapper.git",
"reference": "87c12734877c97ac7274ad145592a2c7efcfa34f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/asset-mapper/zipball/87c12734877c97ac7274ad145592a2c7efcfa34f",
"reference": "87c12734877c97ac7274ad145592a2c7efcfa34f",
"shasum": ""
},
"require": {
"composer/semver": "^3.0",
"php": ">=8.4",
"symfony/filesystem": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0"
},
"require-dev": {
"symfony/asset": "^7.4|^8.0",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/event-dispatcher-contracts": "^3.0",
"symfony/finder": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0",
"symfony/web-link": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\AssetMapper\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Maps directories of assets & makes them available in a public directory with versioned filenames.",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/asset-mapper/tree/v8.0.3"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-19T10:01:18+00:00"
},
{
"name": "symfony/cache",
"version": "v8.0.3",
@@ -6803,6 +6706,86 @@
],
"time": "2025-12-05T14:08:45+00:00"
},
{
"name": "symfony/ux-react",
"version": "v2.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-react.git",
"reference": "42ee2b86e3af8493e4a008ebe2af166c2c3d4d05"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-react/zipball/42ee2b86e3af8493e4a008ebe2af166c2c3d4d05",
"reference": "42ee2b86e3af8493e4a008ebe2af166c2c3d4d05",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/stimulus-bundle": "^2.9.1"
},
"require-dev": {
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
"symfony/finder": "^5.4|^6.0|^7.0|^8.0",
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\React\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Integration of React in Symfony",
"homepage": "https://symfony.com",
"keywords": [
"symfony-ux"
],
"support": {
"source": "https://github.com/symfony/ux-react/tree/v2.34.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-21T22:29:11+00:00"
},
{
"name": "symfony/ux-turbo",
"version": "v2.32.0",

View File

@@ -13,4 +13,6 @@ return [
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\UX\React\ReactBundle::class => ['all' => true],
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
];

View File

@@ -1,11 +0,0 @@
framework:
asset_mapper:
# The paths to make available to the asset mapper.
paths:
- assets/
missing_import_mode: strict
when@prod:
framework:
asset_mapper:
missing_import_mode: warn

View File

@@ -0,0 +1,5 @@
pentatrion_vite:
default_build: app
builds:
app:
build_directory: build

View File

@@ -1,39 +1,37 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
default_target_path: /
logout:
path: app_logout
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
when@test:
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
cost: 4
time_cost: 3
memory_cost: 10

View File

@@ -280,7 +280,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* },
* asset_mapper?: bool|array{ // Asset Mapper configuration
* enabled?: bool|Param, // Default: true
* enabled?: bool|Param, // Default: false
* paths?: array<string, scalar|null|Param>,
* excluded_patterns?: list<scalar|null|Param>,
* exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true
@@ -1455,6 +1455,38 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* generate_final_classes?: bool|Param, // Default: true
* generate_final_entities?: bool|Param, // Default: false
* }
* @psalm-type ReactConfig = array{
* controllers_path?: scalar|null|Param, // The path to the directory where React controller components are stored - relevant only when using symfony/asset-mapper. // Default: "%kernel.project_dir%/assets/react/controllers"
* name_glob?: list<scalar|null|Param>,
* }
* @psalm-type PentatrionViteConfig = array{
* public_directory?: scalar|null|Param, // Default: "public"
* build_directory?: scalar|null|Param, // we only need build_directory to locate entrypoints.json file, it's the "base" vite config parameter without slashes. // Default: "build"
* proxy_origin?: scalar|null|Param, // Allows to use different origin for asset proxy, eg. http://host.docker.internal:5173 // Default: null
* absolute_url?: bool|Param, // Prepend the rendered link and script tags with an absolute URL. // Default: false
* throw_on_missing_entry?: scalar|null|Param, // Throw exception when entry is not present in the entrypoints file // Default: false
* throw_on_missing_asset?: scalar|null|Param, // Throw exception when asset is not present in the manifest file // Default: true
* cache?: bool|Param, // Enable caching of the entry point file(s) // Default: false
* preload?: "none"|"link-tag"|"link-header"|Param, // preload all rendered script and link tags automatically via the http2 Link header. (symfony/web-link is required) Instead <link rel="modulepreload"> will be used. // Default: "link-tag"
* crossorigin?: false|true|"anonymous"|"use-credentials"|Param, // crossorigin value, can be false, true (default), anonymous (same as true) or use-credentials // Default: true
* script_attributes?: list<scalar|null|Param>,
* link_attributes?: list<scalar|null|Param>,
* preload_attributes?: list<scalar|null|Param>,
* default_build?: scalar|null|Param, // Deprecated: The "default_build" option is deprecated. Use "default_config" instead. // Default: null
* builds?: array<string, array{ // Default: []
* build_directory?: scalar|null|Param, // Default: "build"
* script_attributes?: list<scalar|null|Param>,
* link_attributes?: list<scalar|null|Param>,
* preload_attributes?: list<scalar|null|Param>,
* }>,
* default_config?: scalar|null|Param, // Default: null
* configs?: array<string, array{ // Default: []
* build_directory?: scalar|null|Param, // Default: "build"
* script_attributes?: list<scalar|null|Param>,
* link_attributes?: list<scalar|null|Param>,
* preload_attributes?: list<scalar|null|Param>,
* }>,
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1468,6 +1500,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* twig_extra?: TwigExtraConfig,
* security?: SecurityConfig,
* monolog?: MonologConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1484,6 +1518,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* security?: SecurityConfig,
* monolog?: MonologConfig,
* maker?: MakerConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1498,6 +1534,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* twig_extra?: TwigExtraConfig,
* security?: SecurityConfig,
* monolog?: MonologConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1513,6 +1551,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* twig_extra?: TwigExtraConfig,
* security?: SecurityConfig,
* monolog?: MonologConfig,
* react?: ReactConfig,
* pentatrion_vite?: PentatrionViteConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,

View File

@@ -0,0 +1,9 @@
when@dev:
_pentatrion_vite:
prefix: /build
resource: "@PentatrionViteBundle/Resources/config/routing.yaml"
_profiler_vite:
path: /_profiler/vite
defaults:
_controller: Pentatrion\ViteBundle\Controller\ProfilerController::info

View File

@@ -6,7 +6,6 @@ services:
target: dev
environment:
APP_ENV: dev
volumes:
- .:/app
- vendor:/app/vendor
@@ -17,6 +16,21 @@ services:
ports:
- "0.0.0.0:5432:5432"
node:
build:
context: .
dockerfile: docker/node/Dockerfile
target: dev
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:latest
volumes:
- .:/app
- vendor:/app/vendor
- node_modules:/app/node_modules
ports:
- "5173:5173"
depends_on:
- app
volumes:
vendor:
node_modules:

View File

@@ -4,6 +4,8 @@ services:
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"

View File

@@ -30,6 +30,21 @@ ENV APP_ENV=dev \
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
###
@@ -40,6 +55,9 @@ 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/

24
docker/node/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
# Auth + React Frontend — Design Spec
## Context
Actorle is a Symfony 8.0 word game (Wordle-like for actors) running on FrankenPHP with PostgreSQL. The app currently has no authentication and uses Asset Mapper with Stimulus for a minimal frontend. This spec covers adding user authentication and migrating to a React-based interactive frontend via SymfonyUX.
## Approach
**Hybrid Twig + React** (SymfonyUX React option 2):
- Symfony stays in control of routing, sessions, and page rendering via Twig
- Interactive parts (the game grid) are React components mounted in Twig via `{{ react_component() }}`
- Auth pages (login, register) remain pure Twig — no benefit from React here
## 1. Frontend Migration: Asset Mapper to Vite
### Remove Asset Mapper
- Remove `symfony/asset-mapper` from composer
- Delete `importmap.php`
- Remove `asset_mapper.yaml` config
### Install Vite
- Install `pentatrion/vite-bundle` (Symfony Vite integration)
- Create `vite.config.js` at project root with `@vitejs/plugin-react`
- Update `base.html.twig` to use Vite's `{{ vite_entry_link_tags('app') }}` and `{{ vite_entry_script_tags('app') }}` instead of `{{ importmap() }}`
### Install React
- Install `symfony/ux-react`
- npm dependencies: `react`, `react-dom`, `@vitejs/plugin-react`
- The UX React bundle auto-registers a Stimulus controller that mounts React components
### Assets Structure
```
assets/
├── app.js (entry point: imports styles + Stimulus bootstrap)
├── bootstrap.js (Stimulus app initialization)
├── styles/
│ └── app.css
├── controllers/ (Stimulus controllers)
│ └── csrf_protection_controller.js
└── react/
└── controllers/ (React components mountable via {{ react_component() }})
├── GameGrid.jsx
├── GameRow.jsx
├── LetterInput.jsx
└── ActorPopover.jsx
```
## 2. Docker Setup
### New service: `docker/node/Dockerfile`
- Base image: `node:22-alpine`
- Working directory: `/app`
- Runs `npm install` and `npx vite` dev server on port 5173
### docker-compose.override.yaml (dev)
Add `node` service:
```yaml
node:
build:
context: .
dockerfile: docker/node/Dockerfile
volumes:
- .:/app
- node_modules:/app/node_modules
ports:
- "5173:5173"
```
### Production build
- The `docker/node/Dockerfile` has a `build` stage that runs `npm run build`
- The `docker/app/Dockerfile` prod stage copies built assets from the node build stage via `COPY --from=`
- No Node.js runtime in production — only the compiled static assets
## 3. Authentication
### User Entity
- Fields: `id` (int, auto), `email` (string, unique), `password` (hashed), `roles` (json)
- Implements `UserInterface` and `PasswordAuthenticatedUserInterface`
- Doctrine repository: `UserRepository`
- Database migration for the `user` table
### Security Configuration (`security.yaml`)
```yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
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 }
```
### Controllers & Routes
**SecurityController:**
- `GET /login` — renders login form (`templates/security/login.html.twig`)
- `POST /login` — handled by Symfony's `form_login` authenticator
- `GET /logout` — handled by Symfony's logout handler
**RegistrationController:**
- `GET /register` — renders registration form (`templates/security/register.html.twig`)
- `POST /register` — validates form, hashes password, persists User, redirects to `/login`
### Templates
- `templates/security/login.html.twig` — email + password fields, CSRF token, error display, link to register
- `templates/security/register.html.twig` — email + password + confirm password fields, CSRF token, validation errors, link to login
- Both extend `base.html.twig`
- Pure Twig, no React
## 4. Game Grid React Components
### Data Flow
1. `HomepageController::index()` prepares grid data (actors, letters, target) as it does today
2. Data is passed as JSON props to the React component in Twig:
```twig
{{ react_component('GameGrid', { grid: gridData, targetActorId: targetId }) }}
```
3. React hydrates client-side and manages all interactivity
### Components
**`GameGrid.jsx`** — Root component
- Receives `grid` (array of rows) and `targetActorId` as props
- Renders a list of `GameRow` components
- Manages global game state (which letters have been guessed)
**`GameRow.jsx`** — One row = one actor
- Receives row data (actor name, highlighted letter index)
- Renders a sequence of `LetterInput` components
- Contains a button that triggers `ActorPopover`
**`LetterInput.jsx`** — Single character input
- `<input maxLength={1}>` styled to look like a game tile
- On `keyup`: if a character was typed, move focus to the next `LetterInput`
- On `Backspace`: clear and move focus to previous input
- Highlighted letter has distinct styling (red background, as current CSS)
**`ActorPopover.jsx`** — Info popover
- Uses `@floating-ui/react` for positioning
- Triggered by a button click on the row
- Displays actor name, movie info, or hints
- Dismissible by clicking outside or pressing Escape
### Popover Library
- `@floating-ui/react` — modern, lightweight, React-native successor to Popper.js
## 5. What Does NOT Change
- `HomepageController` — same game logic, adapted only to pass data as props to React instead of Twig variables (listed in Modified Files below)
- Entities: `Actor`, `Movie`, `MovieRole` — untouched
- PostgreSQL database and existing migrations — untouched (new migration only adds `user` table)
- FrankenPHP server — untouched
- Makefile — extended with node/npm targets, existing targets unchanged
## 6. File Changes Summary
### New Files
- `docker/node/Dockerfile`
- `vite.config.js`
- `package.json` (replaces importmap-based setup)
- `src/Entity/User.php`
- `src/Repository/UserRepository.php`
- `src/Controller/SecurityController.php`
- `src/Controller/RegistrationController.php`
- `src/Form/RegistrationType.php`
- `templates/security/login.html.twig`
- `templates/security/register.html.twig`
- `assets/react/controllers/GameGrid.jsx`
- `assets/react/controllers/GameRow.jsx`
- `assets/react/controllers/LetterInput.jsx`
- `assets/react/controllers/ActorPopover.jsx`
- Migration file for `user` table
### Modified Files
- `composer.json` — remove asset-mapper, add vite-bundle + ux-react
- `config/packages/security.yaml` — full auth config
- `templates/base.html.twig` — Vite tags instead of importmap
- `templates/homepage/index.html.twig` — replace HTML grid with `{{ react_component() }}`
- `docker-compose.override.yaml` — add node service
- `docker-compose.yaml` — add node build step for prod
- `docker/app/Dockerfile` — prod stage copies built JS assets
- `assets/app.js` — entry point adjustments for Vite
- `src/Controller/HomepageController.php` — pass grid data as JSON props to React component
- `Makefile` — add npm/node targets

View File

@@ -1,28 +0,0 @@
<?php
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
],
];

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260328121534 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE "user"');
}
}

4688
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"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-swc": "^4.3.0",
"vite": "^6.0",
"vite-plugin-symfony": "^8.0"
}
}

View File

@@ -18,7 +18,7 @@ class HomepageController extends AbstractController
private readonly ActorRepository $actorRepository
) {}
#[Route('/')]
#[Route('/', name: 'app_homepage')]
public function index(SerializerInterface $serializer): Response
{
// Final actor to be guessed
@@ -64,9 +64,15 @@ class HomepageController extends AbstractController
$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', [
'mainActor' => $mainActor,
'actors' => $actors,
'grid' => $grid,
'width' => $width,
'middle' => $middle,
]);

View File

@@ -0,0 +1,47 @@
<?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,
]);
}
}

View File

@@ -0,0 +1,32 @@
<?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.');
}
}

87
src/Entity/User.php Normal file
View File

@@ -0,0 +1,87 @@
<?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
{
}
}

View File

@@ -0,0 +1,45 @@
<?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,
]);
}
}

View File

@@ -0,0 +1,34 @@
<?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();
}
}

View File

@@ -35,6 +35,15 @@
"migrations/.gitignore"
]
},
"pentatrion/vite-bundle": {
"version": "8.2",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "6.5",
"ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6"
}
},
"phpunit/phpunit": {
"version": "12.5",
"recipe": {
@@ -50,21 +59,6 @@
"bin/phpunit"
]
},
"symfony/asset-mapper": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
]
},
"symfony/console": {
"version": "8.0",
"recipe": {
@@ -270,6 +264,18 @@
"templates/base.html.twig"
]
},
"symfony/ux-react": {
"version": "2.34",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.9",
"ref": "e970076b31d602ae6e2106cf91a82c7e1f7ddff2"
},
"files": [
"assets/react/controllers/Hello.jsx"
]
},
"symfony/ux-turbo": {
"version": "2.32",
"recipe": {

View File

@@ -5,10 +5,11 @@
<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 %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{{ vite_entry_script_tags('app') }}
{% endblock %}
</head>
<body>

View File

@@ -1,26 +1,9 @@
{% extends 'base.html.twig' %}
{% block body %}
<table id="actors">
{% set iActor = 0 %}
{% for mainChar in mainActor.name|split('') %}
{% if not mainChar|match('/[a-zA-Z]/') %}
<tr><td></td></tr>
{% else %}
{% set actor = actors[iActor] %}
<tr>
{% set i = 0 %}
{% set start = middle - actor.pos %}
{% for c in range(0, width) %}
{% if c >= start and c - start < actor.actor.name|length %}
<td {% if c - start == actor.pos %}style="color:red;"{% endif %}>{{ actor.actor.name|slice(c - start, 1)|upper }}</td>
{% else %}
<td></td>
{% endif %}
{% endfor %}
</tr>
{% set iActor = iActor + 1 %}
{% endif %}
{% endfor %}
</table>
{{ react_component('GameGrid', {
grid: grid,
width: width,
middle: middle,
}) }}
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% 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 %}

View File

@@ -0,0 +1,19 @@
{% 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 %}

32
vite.config.js Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import symfonyPlugin from 'vite-plugin-symfony';
export default defineConfig({
plugins: [
symfonyPlugin({
stimulus: true,
}),
],
esbuild: {
jsx: 'automatic',
},
build: {
rollupOptions: {
input: {
app: './assets/app.js',
},
},
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
origin: 'http://localhost:5173',
cors: true,
hmr: {
host: 'localhost',
port: 5173,
protocol: 'ws',
},
},
});