Compare commits
23 Commits
4c5e82cb9d
...
cb0ea949f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb0ea949f6 | ||
|
|
7abca03122 | ||
|
|
a6064c2bdb | ||
|
|
04301642bc | ||
|
|
783a8492e9 | ||
|
|
d590120306 | ||
|
|
deb826401d | ||
|
|
6290cef3fe | ||
|
|
29667f0b1e | ||
|
|
ac5bd38954 | ||
|
|
ad014c2547 | ||
|
|
748b1c7a08 | ||
|
|
1640d8d9d9 | ||
|
|
6d40c4ce08 | ||
|
|
64949d2ec2 | ||
|
|
1720246382 | ||
|
|
376a01bff5 | ||
|
|
d175202163 | ||
|
|
c3dab636b1 | ||
|
|
f7a9be6a38 | ||
|
|
bd3996f4a9 | ||
|
|
8af386bd5c | ||
|
|
e376a97dad |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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/
|
||||
|
||||
9
Makefile
9
Makefile
@@ -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}'
|
||||
|
||||
@@ -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
19
assets/bootstrap.js
vendored
Normal 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;
|
||||
};
|
||||
@@ -9,6 +9,12 @@
|
||||
"enabled": false,
|
||||
"fetch": "eager"
|
||||
}
|
||||
},
|
||||
"@symfony/ux-react": {
|
||||
"react": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoints": []
|
||||
|
||||
40
assets/react/controllers/ActorPopover.jsx
Normal file
40
assets/react/controllers/ActorPopover.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
assets/react/controllers/GameGrid.jsx
Normal file
20
assets/react/controllers/GameGrid.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
assets/react/controllers/GameRow.jsx
Normal file
43
assets/react/controllers/GameRow.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
assets/react/controllers/Hello.jsx
Normal file
5
assets/react/controllers/Hello.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function (props) {
|
||||
return <div>Hello {props.fullName}</div>;
|
||||
}
|
||||
26
assets/react/controllers/LetterInput.jsx
Normal file
26
assets/react/controllers/LetterInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
7
assets/vendor/@hotwired/stimulus/stimulus.index.js
vendored
Normal file
7
assets/vendor/@hotwired/stimulus/stimulus.index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
30
assets/vendor/@hotwired/turbo/turbo.index.js
vendored
Normal file
30
assets/vendor/@hotwired/turbo/turbo.index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
assets/vendor/installed.php
vendored
Normal file
22
assets/vendor/installed.php
vendored
Normal 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 (
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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
301
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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
|
||||
5
config/packages/pentatrion_vite.yaml
Normal file
5
config/packages/pentatrion_vite.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
pentatrion_vite:
|
||||
default_build: app
|
||||
builds:
|
||||
app:
|
||||
build_directory: build
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
9
config/routes/pentatrion_vite.yaml
Normal file
9
config/routes/pentatrion_vite.yaml
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
24
docker/node/Dockerfile
Normal 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
|
||||
1526
docs/superpowers/plans/2026-03-28-auth-react-frontend.md
Normal file
1526
docs/superpowers/plans/2026-03-28-auth-react-frontend.md
Normal file
File diff suppressed because it is too large
Load Diff
201
docs/superpowers/specs/2026-03-28-auth-react-frontend-design.md
Normal file
201
docs/superpowers/specs/2026-03-28-auth-react-frontend-design.md
Normal 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
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
32
migrations/Version20260328121534.php
Normal file
32
migrations/Version20260328121534.php
Normal 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
4688
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
47
src/Controller/RegistrationController.php
Normal file
47
src/Controller/RegistrationController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
src/Controller/SecurityController.php
Normal file
32
src/Controller/SecurityController.php
Normal 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
87
src/Entity/User.php
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
||||
45
src/Form/RegistrationType.php
Normal file
45
src/Form/RegistrationType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
34
src/Repository/UserRepository.php
Normal file
34
src/Repository/UserRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
36
symfony.lock
36
symfony.lock
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
27
templates/security/login.html.twig
Normal file
27
templates/security/login.html.twig
Normal 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 %}
|
||||
19
templates/security/register.html.twig
Normal file
19
templates/security/register.html.twig
Normal 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
32
vite.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user