Compare commits
100 Commits
9cb5c6e2a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
843009e193 | ||
|
|
2e7d7ecf44 | ||
|
|
6b514aa87b | ||
|
|
51a9f49797 | ||
|
|
b637b725d8 | ||
|
|
ba715d69a0 | ||
|
|
94ff0ced63 | ||
|
|
9c095a76eb | ||
|
|
f291df0fcf | ||
|
|
67571e8b33 | ||
|
|
5fbac8359f | ||
|
|
468b72b419 | ||
|
|
54225ad97b | ||
|
|
295bb16ab7 | ||
|
|
116d7b409e | ||
|
|
369893a77e | ||
|
|
8c73a22eff | ||
|
|
087b063f1f | ||
|
|
0e3b17bb7d | ||
|
|
246d6fc740 | ||
|
|
353ffddeea | ||
|
|
fb13a8819d | ||
|
|
0fd0b85b8f | ||
|
|
8aa33ccefc | ||
|
|
d4d2272396 | ||
|
|
6c1e4cb38b | ||
|
|
859a5a1067 | ||
|
|
acc266739d | ||
|
|
76013afb1c | ||
|
|
d2d211a228 | ||
|
|
116812b3f8 | ||
|
|
c5d359bb0c | ||
|
|
ded3d063c6 | ||
|
|
2e65b2805a | ||
|
|
dba9b985ee | ||
|
|
a37ac1debd | ||
|
|
6a844542ad | ||
|
|
3edde1c7db | ||
|
|
8942e7f608 | ||
|
|
f6d180474a | ||
|
|
6cbebb6367 | ||
|
|
ecfc80c349 | ||
|
|
dc5e14531f | ||
|
|
1fd6dcc5d3 | ||
|
|
0706d99c82 | ||
|
|
273ea49ed0 | ||
|
|
8d413b5c57 | ||
|
|
91f45448f0 | ||
|
|
42a3567e1c | ||
|
|
32ae77da53 | ||
|
|
c2efdd4eeb | ||
|
|
7f3738007d | ||
|
|
e3ee26e070 | ||
|
|
cdcd3312ef | ||
|
|
4fb1a25469 | ||
|
|
335a55562f | ||
|
|
ba9a3fba5d | ||
|
|
86bf2eb1d3 | ||
|
|
1f80b554fd | ||
|
|
52c40f1ecc | ||
|
|
c35e239450 | ||
|
|
2f5ba701b6 | ||
|
|
96adefbb1e | ||
|
|
a6b3a93d5c | ||
|
|
884168aa49 | ||
|
|
ef155463ab | ||
|
|
1d7c215887 | ||
|
|
665233425a | ||
|
|
ff9a48448c | ||
|
|
55145c366f | ||
|
|
90ca2b946d | ||
|
|
1eb7817182 | ||
|
|
6cd6c1ed47 | ||
|
|
3c15c12255 | ||
|
|
99abd78495 | ||
|
|
bcf6cd1a03 | ||
|
|
503c474461 | ||
|
|
f42a3ba286 | ||
|
|
23c291d2c2 | ||
|
|
201faf3789 | ||
|
|
5fc6b4a53b | ||
|
|
6edc122ff6 | ||
|
|
b0024bbcf5 | ||
|
|
300699fa82 | ||
|
|
c9880baddb | ||
|
|
1ea07a2438 | ||
|
|
a348de01b0 | ||
|
|
4f8eb5f3dc | ||
|
|
2cfbe191cf | ||
|
|
4955c5bde9 | ||
|
|
98be393e3c | ||
|
|
2d768e8b52 | ||
|
|
dedc41e237 | ||
|
|
bbbfb895af | ||
|
|
1bf8afd88e | ||
|
|
7be4de6967 | ||
|
|
5f7ddcd3cc | ||
|
|
5d16d28c59 | ||
|
|
def97304a9 | ||
|
|
720e8e0cf9 |
@@ -1,6 +1,6 @@
|
|||||||
vendor/
|
vendor/
|
||||||
var/
|
var/
|
||||||
.env.local
|
.env
|
||||||
.env.*.local
|
.env.*.local
|
||||||
/public/assets/
|
/public/assets/
|
||||||
/assets/vendor/
|
/assets/vendor/
|
||||||
|
|||||||
@@ -8,6 +8,4 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
|||||||
|
|
||||||
MAILER_DSN=null://null
|
MAILER_DSN=null://null
|
||||||
|
|
||||||
TMDB_API_TOKEN=
|
|
||||||
|
|
||||||
SERVER_NAME=
|
SERVER_NAME=
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
# define your env variables for the test env here
|
# define your env variables for the test env here
|
||||||
KERNEL_CLASS='App\Kernel'
|
KERNEL_CLASS='App\Kernel'
|
||||||
APP_SECRET='$ecretf0rt3st'
|
APP_SECRET='$ecretf0rt3st'
|
||||||
|
POSTGRES_DB=app_test
|
||||||
|
|||||||
13
.gitea/issue_template/bug_report.yaml
Normal file
13
.gitea/issue_template/bug_report.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: Report Bug
|
||||||
|
about: Signaler un bug rencontré dans le jeu
|
||||||
|
labels:
|
||||||
|
- "type: bug"
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Décrivez le bug rencontré
|
||||||
|
placeholder: Décrivez ce qui s'est passé et ce que vous attendiez...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
name: Build and Push Docker Images
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-app:
|
|
||||||
name: Build app image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check if app image should be rebuilt
|
|
||||||
id: changed
|
|
||||||
run: |
|
|
||||||
# Rebuild if docker/app/ or any PHP/config source changed
|
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD | grep -E '^(docker/app/|src/|config/|templates/|migrations/|composer\.(json|lock)|symfony\.lock|\.env)' | wc -l)
|
|
||||||
echo "changed=$([ "$CHANGED" -gt 0 ] && echo true || echo false)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea registry
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.lclr.dev
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push app
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/app/Dockerfile
|
|
||||||
target: prod
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest
|
|
||||||
git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:${{ github.sha }}
|
|
||||||
cache-from: type=registry,ref=git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:buildcache
|
|
||||||
cache-to: type=registry,ref=git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:buildcache,mode=max
|
|
||||||
|
|
||||||
build-database:
|
|
||||||
name: Build database image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check if database image should be rebuilt
|
|
||||||
id: changed
|
|
||||||
run: |
|
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD | grep -q '^docker/database/' && echo true || echo false)
|
|
||||||
echo "changed=$CHANGED" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea registry
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.lclr.dev
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push database
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: docker/database
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
git.lclr.dev/thibaud-lclr/ltbxd-actorle/database:latest
|
|
||||||
git.lclr.dev/thibaud-lclr/ltbxd-actorle/database:${{ github.sha }}
|
|
||||||
cache-from: type=registry,ref=git.lclr.dev/thibaud-lclr/ltbxd-actorle/database:buildcache
|
|
||||||
cache-to: type=registry,ref=git.lclr.dev/thibaud-lclr/ltbxd-actorle/database:buildcache,mode=max
|
|
||||||
|
|
||||||
build-node:
|
|
||||||
name: Build node image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check if node image should be rebuilt
|
|
||||||
id: changed
|
|
||||||
run: |
|
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD | grep -E '^(docker/node/|assets/|package(-lock)?\.json|vite\.config\.js)' | wc -l)
|
|
||||||
echo "changed=$([ "$CHANGED" -gt 0 ] && echo true || echo false)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea registry
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.lclr.dev
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push node
|
|
||||||
if: steps.changed.outputs.changed == 'true'
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/node/Dockerfile
|
|
||||||
target: dev
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:latest
|
|
||||||
git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:${{ github.sha }}
|
|
||||||
cache-from: type=registry,ref=git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:buildcache
|
|
||||||
cache-to: type=registry,ref=git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:buildcache,mode=max
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,9 @@
|
|||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
/.env.local
|
/.env
|
||||||
/.env.local.php
|
/.env.local.php
|
||||||
/.env.*.local
|
/.env.*.local
|
||||||
/config/secrets/prod/prod.decrypt.private.php
|
/.env
|
||||||
|
/config/secrets/*/*.decrypt.private.php
|
||||||
/public/bundles/
|
/public/bundles/
|
||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
|
|||||||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Ignored default folder with query files
|
|
||||||
/queries/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
5718
.idea/commandlinetools/Symfony_13_01_2026_13_23.xml
generated
5718
.idea/commandlinetools/Symfony_13_01_2026_13_23.xml
generated
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified"
|
|
||||||
xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
|
||||||
<xs:element name="framework" type="frameworkType"/>
|
|
||||||
<xs:complexType name="commandType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element type="xs:string" name="name" minOccurs="1" maxOccurs="1"/>
|
|
||||||
<xs:element type="xs:string" name="params" minOccurs="0" maxOccurs="1"/>
|
|
||||||
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
|
|
||||||
<xs:element type="optionsBeforeType" name="optionsBefore" minOccurs="0" maxOccurs="1"/>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
<xs:complexType name="frameworkType">
|
|
||||||
<xs:sequence>
|
|
||||||
<xs:element type="xs:string" name="extraData" minOccurs="0" maxOccurs="1"/>
|
|
||||||
<xs:element type="commandType" name="command" maxOccurs="unbounded" minOccurs="0"/>
|
|
||||||
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
|
|
||||||
</xs:sequence>
|
|
||||||
<xs:attribute type="xs:string" name="name" use="required"/>
|
|
||||||
<xs:attribute type="xs:string" name="invoke" use="required"/>
|
|
||||||
<xs:attribute type="xs:string" name="alias" use="required"/>
|
|
||||||
<xs:attribute type="xs:boolean" name="enabled" use="required"/>
|
|
||||||
<xs:attribute type="xs:integer" name="version" use="required"/>
|
|
||||||
<xs:attribute type="xs:string" name="frameworkId" use="optional"/>
|
|
||||||
</xs:complexType>
|
|
||||||
<xs:complexType name="optionsBeforeType">
|
|
||||||
<xs:sequence>
|
|
||||||
<xs:element type="optionType" name="option" maxOccurs="unbounded" minOccurs="0"/>
|
|
||||||
</xs:sequence>
|
|
||||||
</xs:complexType>
|
|
||||||
<xs:complexType name="optionType">
|
|
||||||
<xs:sequence>
|
|
||||||
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
|
|
||||||
</xs:sequence>
|
|
||||||
<xs:attribute type="xs:string" name="name" use="required"/>
|
|
||||||
<xs:attribute type="xs:string" name="shortcut" use="optional"/>
|
|
||||||
<xs:attribute name="pattern" use="optional">
|
|
||||||
<xs:simpleType>
|
|
||||||
<xs:restriction base="xs:string">
|
|
||||||
<xs:enumeration value="space"/>
|
|
||||||
<xs:enumeration value="equals"/>
|
|
||||||
<xs:enumeration value="unknown"/>
|
|
||||||
</xs:restriction>
|
|
||||||
</xs:simpleType>
|
|
||||||
</xs:attribute>
|
|
||||||
</xs:complexType>
|
|
||||||
</xs:schema>
|
|
||||||
12
.idea/dataSources.xml
generated
12
.idea/dataSources.xml
generated
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
|
||||||
<data-source source="LOCAL" name="app@localhost" uuid="587aeb55-4ca6-47d6-8bc5-b430816b19dd">
|
|
||||||
<driver-ref>postgresql</driver-ref>
|
|
||||||
<synchronize>true</synchronize>
|
|
||||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
|
||||||
<jdbc-url>jdbc:postgresql://localhost:5432/app</jdbc-url>
|
|
||||||
<working-dir>$ProjectFileDir$</working-dir>
|
|
||||||
</data-source>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/dictionaries/project.xml
generated
8
.idea/dictionaries/project.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<component name="ProjectDictionaryState">
|
|
||||||
<dictionary name="project">
|
|
||||||
<words>
|
|
||||||
<w>Letterboxd</w>
|
|
||||||
<w>tmdb</w>
|
|
||||||
</words>
|
|
||||||
</dictionary>
|
|
||||||
</component>
|
|
||||||
8
.idea/laravel-idea.xml
generated
8
.idea/laravel-idea.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="InertiaPackage">
|
|
||||||
<option name="directoryPaths">
|
|
||||||
<list />
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
137
.idea/ltbxd-actorle.iml
generated
137
.idea/ltbxd-actorle.iml
generated
@@ -1,137 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="WEB_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="App\" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="App\Tests\" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/dbal" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/deprecations" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/event-manager" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/inflector" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/instantiator" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/lexer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/migrations" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-text-template" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-timer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/phpunit" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/cache" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/comparator" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/complexity" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/diff" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/environment" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/exporter" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/global-state" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/lines-of-code" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-enumerator" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-reflector" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/recursion-context" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/type" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/version" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/staabm/side-effects-detector" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/asset" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/asset-mapper" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/browser-kit" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache-contracts" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/clock" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/config" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/console" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/css-selector" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/debug-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/form" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/framework-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/notifier" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-icu" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-mbstring" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php85" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/process" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-access" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-info" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/routing" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/runtime" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-core" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-csrf" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stimulus-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/ux-turbo" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/var/cache" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
12
.idea/misc.xml
generated
12
.idea/misc.xml
generated
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MaterialThemeProjectNewConfig">
|
|
||||||
<option name="metadata">
|
|
||||||
<MTProjectMetadataState>
|
|
||||||
<option name="migrated" value="true" />
|
|
||||||
<option name="pristineConfig" value="false" />
|
|
||||||
<option name="userId" value="-4f4ad9c:19537e848e0:-7ff1" />
|
|
||||||
</MTProjectMetadataState>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/ltbxd-actorle.iml" filepath="$PROJECT_DIR$/.idea/ltbxd-actorle.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
156
.idea/php.xml
generated
156
.idea/php.xml
generated
@@ -1,156 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MessDetectorOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PHPCSFixerOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
|
||||||
<option name="highlightLevel" value="WARNING" />
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PhpIncludePathManager">
|
|
||||||
<include_path>
|
|
||||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/debug-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/notifier" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/form" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
|
||||||
</include_path>
|
|
||||||
</component>
|
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4">
|
|
||||||
<option name="suggestChangeDefaultLanguageLevel" value="false" />
|
|
||||||
</component>
|
|
||||||
<component name="PhpStanOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PhpUnit">
|
|
||||||
<phpunit_settings>
|
|
||||||
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
|
|
||||||
</phpunit_settings>
|
|
||||||
</component>
|
|
||||||
<component name="PsalmOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/symfony2.xml
generated
6
.idea/symfony2.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Symfony2PluginSettings">
|
|
||||||
<option name="pluginEnabled" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
Makefile
10
Makefile
@@ -46,11 +46,21 @@ db\:reset: ## Recrée la base et rejoue les migrations (⚠ perd les données)
|
|||||||
docker compose exec app php bin/console doctrine:database:create --no-interaction
|
docker compose exec app php bin/console doctrine:database:create --no-interaction
|
||||||
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
|
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
symfony\:secrets-set: ## Ajoute un secret Symfony (ex: make symfony:secrets-set NAME="S3_ACCESS_KEY")
|
||||||
|
docker compose exec app php bin/console secrets:set $(NAME)
|
||||||
|
|
||||||
symfony\:console: ## Lance une commande Symfony (ex: make symfony:console CMD="cache:clear")
|
symfony\:console: ## Lance une commande Symfony (ex: make symfony:console CMD="cache:clear")
|
||||||
docker compose exec app php bin/console $(CMD)
|
docker compose exec app php bin/console $(CMD)
|
||||||
|
|
||||||
|
php\:console: ## Lance bin/console avec arguments (ex: make php:console -- cache:clear --env=prod)
|
||||||
|
docker compose exec app php bin/console $(filter-out $@,$(MAKECMDGOALS))
|
||||||
|
|
||||||
|
%:
|
||||||
|
@:
|
||||||
|
|
||||||
symfony\:cache-clear: ## Vide le cache Symfony
|
symfony\:cache-clear: ## Vide le cache Symfony
|
||||||
docker compose exec app php bin/console cache:clear
|
docker compose exec app php bin/console cache:clear
|
||||||
|
docker compose restart messenger
|
||||||
|
|
||||||
test: ## Lance les tests PHPUnit
|
test: ## Lance les tests PHPUnit
|
||||||
docker compose exec app php bin/phpunit
|
docker compose exec app php bin/phpunit
|
||||||
|
|||||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Actorle
|
||||||
|
|
||||||
|
Un jeu de devinettes inspiré de Wordle, mais autour des acteurs de cinéma. Le joueur doit deviner le nom d'un acteur lettre par lettre, en s'aidant d'indices liés aux films, personnages et récompenses des acteurs.
|
||||||
|
|
||||||
|
## Principe du jeu
|
||||||
|
|
||||||
|
1. Le joueur lance une partie. Le système choisit un acteur aléatoire (filtré par popularité TMDB).
|
||||||
|
2. Pour chaque lettre du nom de l'acteur, un **acteur-indice** est attribué, dont le nom commence par cette lettre.
|
||||||
|
3. Chaque ligne de la grille propose un indice sur l'acteur-indice : un **film** dans lequel il a joué, un **personnage** qu'il a incarné, ou une **récompense** qu'il a reçue.
|
||||||
|
4. Le joueur utilise ces indices pour reconstituer le nom de l'acteur principal.
|
||||||
|
|
||||||
|
Les données proviennent de la filmographie Letterboxd de l'utilisateur : seuls les acteurs issus de ses films regardés sont utilisés pour générer les grilles.
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Couche | Technologies |
|
||||||
|
|------------|-----------------------------------------------------------------|
|
||||||
|
| Backend | PHP 8.4, Symfony 8.0, Doctrine ORM, Symfony Messenger |
|
||||||
|
| Frontend | React 19, Stimulus, Turbo, Vite 6 |
|
||||||
|
| Base | PostgreSQL 16 |
|
||||||
|
| Stockage | FlySystem (S3-compatible) |
|
||||||
|
| Serveur | FrankenPHP (dev), Caddy (prod) |
|
||||||
|
| Infra | Docker Compose |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Controller/ # Contrôleurs (Game, Import, Auth)
|
||||||
|
├── Entity/ # Entités Doctrine (Game, Actor, Movie, Award...)
|
||||||
|
├── Provider/ # Fournisseurs de données (GameGridProvider)
|
||||||
|
├── Import/ # Import de données externes (FilmImporter, ActorSyncer, AwardImporter)
|
||||||
|
├── Repository/ # Requêtes Doctrine
|
||||||
|
├── Gateway/ # Intégrations externes (TMDB, Wikidata, Letterboxd)
|
||||||
|
├── Message/ # Messages async (ProcessImport, ImportFilmsBatch)
|
||||||
|
├── MessageHandler/ # Handlers Symfony Messenger
|
||||||
|
├── EventListener/ # Abandon auto des parties anonymes au login
|
||||||
|
├── Form/ # Formulaires (inscription)
|
||||||
|
└── Model/ # DTOs (TMDB, Letterboxd)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intégrations externes
|
||||||
|
|
||||||
|
- **TMDB API** : recherche de films, récupération des castings et crédits
|
||||||
|
- **Wikidata SPARQL** : récupération des récompenses des acteurs (Oscars, BAFTA, etc.)
|
||||||
|
- **Letterboxd** : import de la filmographie via export CSV
|
||||||
|
|
||||||
|
## Import de films
|
||||||
|
|
||||||
|
1. L'utilisateur exporte son historique depuis Letterboxd (CSV).
|
||||||
|
2. Il upload le fichier via l'interface.
|
||||||
|
3. Un message async découpe l'import en **batchs de 50 films**.
|
||||||
|
4. Chaque batch recherche les films sur TMDB, synchronise les acteurs et importe leurs récompenses depuis Wikidata.
|
||||||
|
5. L'avancement est consultable en temps réel via l'API (`GET /api/imports/latest`).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Docker et Docker Compose
|
||||||
|
- Un token API TMDB
|
||||||
|
|
||||||
|
### Lancement en développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrer les conteneurs (app, messenger, database, node)
|
||||||
|
make dev:up
|
||||||
|
|
||||||
|
# Installer les dépendances npm
|
||||||
|
make node:install
|
||||||
|
|
||||||
|
# Exécuter les migrations
|
||||||
|
make db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est accessible sur `http://localhost`.
|
||||||
|
Le serveur Vite (HMR) tourne sur le port `5173`.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Les secrets (token TMDB, etc.) se gèrent via Symfony Secrets :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make symfony:secrets-set NAME="TMDB_API_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables d'environnement principales :
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---------------------------|------------------------------------|
|
||||||
|
| `APP_ENV` | Environnement (`dev` / `prod`) |
|
||||||
|
| `APP_SECRET` | Secret Symfony |
|
||||||
|
| `TMDB_API_TOKEN` | Token API TMDB (via secrets) |
|
||||||
|
| `POSTGRES_HOST` | Hôte PostgreSQL |
|
||||||
|
| `POSTGRES_USER` | Utilisateur PostgreSQL |
|
||||||
|
| `POSTGRES_PASSWORD` | Mot de passe PostgreSQL |
|
||||||
|
| `POSTGRES_DB` | Nom de la base |
|
||||||
|
| `MESSENGER_TRANSPORT_DSN` | DSN du transport Messenger |
|
||||||
|
|
||||||
|
## Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev:up # Démarrer le dev
|
||||||
|
make dev:down # Arrêter le dev
|
||||||
|
make dev:logs # Logs en temps réel
|
||||||
|
make dev:shell # Shell dans le conteneur app
|
||||||
|
make db:migrate # Exécuter les migrations
|
||||||
|
make db:migration # Générer une migration
|
||||||
|
make db:reset # Reset complet de la base
|
||||||
|
make test # Lancer les tests PHPUnit
|
||||||
|
make node:build # Build des assets pour la prod
|
||||||
|
make help # Afficher toutes les commandes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build et push des images vers le registry
|
||||||
|
make docker:build
|
||||||
|
make docker:push
|
||||||
|
|
||||||
|
# Sur le serveur de production
|
||||||
|
make prod:up
|
||||||
|
```
|
||||||
|
|
||||||
|
Les images sont hébergées sur le registry Gitea `git.lclr.dev`.
|
||||||
|
|
||||||
|
## Services Docker
|
||||||
|
|
||||||
|
| Service | Description |
|
||||||
|
|-------------|-----------------------------------------------------|
|
||||||
|
| `app` | Application FrankenPHP (dev) / Caddy (prod) |
|
||||||
|
| `messenger` | Worker Symfony Messenger (consomme la queue `async`) |
|
||||||
|
| `database` | PostgreSQL 16 |
|
||||||
|
| `node` | Vite dev server avec HMR (dev uniquement) |
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||||
import './bootstrap.js';
|
import './bootstrap.js';
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
|
|||||||
10
assets/bootstrap.js
vendored
10
assets/bootstrap.js
vendored
@@ -1,6 +1,16 @@
|
|||||||
import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers';
|
import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers';
|
||||||
|
import DropdownController from './controllers/dropdown_controller.js';
|
||||||
|
import ImportModalController from './controllers/import_modal_controller.js';
|
||||||
|
import ImportStatusController from './controllers/import_status_controller.js';
|
||||||
|
import ImportHelpController from './controllers/import_help_controller.js';
|
||||||
|
import GameConfigController from './controllers/game_config_controller.js';
|
||||||
|
|
||||||
const app = startStimulusApp();
|
const app = startStimulusApp();
|
||||||
|
app.register('dropdown', DropdownController);
|
||||||
|
app.register('import-modal', ImportModalController);
|
||||||
|
app.register('import-status', ImportStatusController);
|
||||||
|
app.register('import-help', ImportHelpController);
|
||||||
|
app.register('game-config', GameConfigController);
|
||||||
|
|
||||||
// Register React components for {{ react_component() }} Twig function.
|
// Register React components for {{ react_component() }} Twig function.
|
||||||
// We register them manually because @symfony/ux-react's registerReactControllerComponents
|
// We register them manually because @symfony/ux-react's registerReactControllerComponents
|
||||||
|
|||||||
24
assets/controllers/dropdown_controller.js
Normal file
24
assets/controllers/dropdown_controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['menu', 'trigger'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this._closeOnClickOutside = this._closeOnClickOutside.bind(this);
|
||||||
|
document.addEventListener('click', this._closeOnClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
document.removeEventListener('click', this._closeOnClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.menuTarget.hidden = !this.menuTarget.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeOnClickOutside(event) {
|
||||||
|
if (!this.element.contains(event.target)) {
|
||||||
|
this.menuTarget.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
assets/controllers/game_config_controller.js
Normal file
33
assets/controllers/game_config_controller.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['hintType', 'awardSection', 'allAwards', 'awardType'];
|
||||||
|
|
||||||
|
enforceMinOneChecked(event) {
|
||||||
|
const checked = this.hintTypeTargets.filter((e) => e.checked);
|
||||||
|
if (checked.length === 0) {
|
||||||
|
event.target.checked = true;
|
||||||
|
}
|
||||||
|
this.toggleAwardSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAwardSection() {
|
||||||
|
const awardChecked = this.hintTypeTargets.find(
|
||||||
|
(el) => el.name === 'hint_award'
|
||||||
|
)?.checked;
|
||||||
|
|
||||||
|
this.awardSectionTarget.style.display = awardChecked ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAllAwards() {
|
||||||
|
const checked = this.allAwardsTarget.checked;
|
||||||
|
this.awardTypeTargets.forEach((el) => {
|
||||||
|
el.checked = checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAllAwards() {
|
||||||
|
const allChecked = this.awardTypeTargets.every((el) => el.checked);
|
||||||
|
this.allAwardsTarget.checked = allChecked;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
assets/controllers/import_help_controller.js
Normal file
20
assets/controllers/import_help_controller.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['overlay'];
|
||||||
|
|
||||||
|
open(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.overlayTarget.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.overlayTarget.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOnBackdrop(event) {
|
||||||
|
if (event.target === this.overlayTarget) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
assets/controllers/import_modal_controller.js
Normal file
60
assets/controllers/import_modal_controller.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['overlay', 'fileInput', 'feedback', 'submitBtn'];
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.overlayTarget.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.overlayTarget.hidden = true;
|
||||||
|
this.fileInputTarget.value = '';
|
||||||
|
this.feedbackTarget.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
const file = this.fileInputTarget.files[0];
|
||||||
|
if (!file) {
|
||||||
|
this._showFeedback('Veuillez sélectionner un fichier.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.csv')) {
|
||||||
|
this._showFeedback('Seuls les fichiers CSV sont acceptés.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitBtnTarget.disabled = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/imports', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this._showFeedback(data.error || 'Une erreur est survenue.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showFeedback('Import lancé !', false);
|
||||||
|
document.dispatchEvent(new CustomEvent('import:started'));
|
||||||
|
setTimeout(() => this.close(), 1500);
|
||||||
|
} catch (e) {
|
||||||
|
this._showFeedback('Une erreur est survenue.', true);
|
||||||
|
} finally {
|
||||||
|
this.submitBtnTarget.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_showFeedback(message, isError) {
|
||||||
|
this.feedbackTarget.textContent = message;
|
||||||
|
this.feedbackTarget.className = isError ? 'modal-feedback error' : 'modal-feedback success';
|
||||||
|
this.feedbackTarget.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
assets/controllers/import_status_controller.js
Normal file
95
assets/controllers/import_status_controller.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['item', 'importBtn'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this._poll();
|
||||||
|
this._interval = setInterval(() => this._poll(), 5000);
|
||||||
|
this._onImportStarted = () => this._poll();
|
||||||
|
document.addEventListener('import:started', this._onImportStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
clearInterval(this._interval);
|
||||||
|
document.removeEventListener('import:started', this._onImportStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _poll() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/imports/latest');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this._update(data);
|
||||||
|
} catch (e) {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_update(data) {
|
||||||
|
if (!data) {
|
||||||
|
this._showDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = data.status === 'pending' || data.status === 'processing';
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
this._showActive(data);
|
||||||
|
} else if (data.status === 'completed') {
|
||||||
|
this._showCompleted(data);
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
this._showFailed();
|
||||||
|
} else {
|
||||||
|
this._showDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_showDefault() {
|
||||||
|
this.importBtnTarget.disabled = false;
|
||||||
|
this.importBtnTarget.textContent = 'Importer ses films';
|
||||||
|
this._removeStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_showActive(data) {
|
||||||
|
this.importBtnTarget.disabled = true;
|
||||||
|
this.importBtnTarget.textContent = 'Import en cours\u2026';
|
||||||
|
|
||||||
|
const progress = data.totalFilms > 0
|
||||||
|
? Math.round((data.processedFilms / data.totalFilms) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
this._setStatus(`${progress}% — ${data.processedFilms}/${data.totalFilms} films`, 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
_showCompleted(data) {
|
||||||
|
this.importBtnTarget.disabled = false;
|
||||||
|
this.importBtnTarget.textContent = 'Importer ses films';
|
||||||
|
|
||||||
|
const imported = data.totalFilms - data.failedFilms;
|
||||||
|
this._setStatus(`Dernier import : ${imported}/${data.totalFilms} films`, 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
_showFailed() {
|
||||||
|
this.importBtnTarget.disabled = false;
|
||||||
|
this.importBtnTarget.textContent = 'Importer ses films';
|
||||||
|
this._setStatus('Dernier import : échoué', 'failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
_setStatus(text, type) {
|
||||||
|
let statusEl = this.itemTarget.querySelector('.import-status-text');
|
||||||
|
if (!statusEl) {
|
||||||
|
statusEl = document.createElement('span');
|
||||||
|
statusEl.className = 'import-status-text';
|
||||||
|
this.itemTarget.appendChild(statusEl);
|
||||||
|
}
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = `import-status-text import-status-${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeStatus() {
|
||||||
|
const statusEl = this.itemTarget.querySelector('.import-status-text');
|
||||||
|
if (statusEl) statusEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,40 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react';
|
import { useFloating, useClick, useDismiss, useInteractions, offset, shift, size, FloatingPortal } from '@floating-ui/react';
|
||||||
|
|
||||||
export default function ActorPopover({ actorName }) {
|
const HINT_ICONS = {
|
||||||
|
film: 'fa-solid fa-film',
|
||||||
|
character: 'fa-solid fa-masks-theater',
|
||||||
|
award: 'fa-solid fa-trophy',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActorPopover({ hintType, hintText }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { refs, floatingStyles, context } = useFloating({
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
open: isOpen,
|
open: isOpen,
|
||||||
onOpenChange: setIsOpen,
|
onOpenChange: setIsOpen,
|
||||||
middleware: [offset(8), flip(), shift()],
|
middleware: [
|
||||||
placement: 'top',
|
offset(8),
|
||||||
|
size({
|
||||||
|
apply({ availableWidth, elements }) {
|
||||||
|
Object.assign(elements.floating.style, {
|
||||||
|
maxWidth: `${availableWidth - 16}px`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
shift({ padding: 8 }),
|
||||||
|
],
|
||||||
|
placement: 'left',
|
||||||
});
|
});
|
||||||
|
|
||||||
const click = useClick(context);
|
const click = useClick(context);
|
||||||
const dismiss = useDismiss(context);
|
const dismiss = useDismiss(context);
|
||||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
||||||
|
|
||||||
|
if (!hintText) return null;
|
||||||
|
|
||||||
|
const iconClass = HINT_ICONS[hintType] || 'fa-solid fa-circle-question';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -23,17 +43,19 @@ export default function ActorPopover({ actorName }) {
|
|||||||
className="popover-trigger"
|
className="popover-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
?
|
<i className={iconClass}></i>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
<FloatingPortal>
|
||||||
<div
|
<div
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
style={floatingStyles}
|
style={floatingStyles}
|
||||||
{...getFloatingProps()}
|
{...getFloatingProps()}
|
||||||
className="actor-popover"
|
className="actor-popover"
|
||||||
>
|
>
|
||||||
<strong>{actorName}</strong>
|
{hintText}
|
||||||
</div>
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import GameRow from './GameRow';
|
import GameRow from './GameRow';
|
||||||
|
import ActorPopover from './ActorPopover';
|
||||||
|
|
||||||
export default function GameGrid({ grid, width, middle }) {
|
export default function GameGrid({ grid, width, middle }) {
|
||||||
return (
|
return (
|
||||||
|
<div className="game-grid-scroll">
|
||||||
<table id="actors">
|
<table id="actors">
|
||||||
<tbody>
|
<tbody>
|
||||||
{grid.map((row, rowIndex) => (
|
{grid.map((row, rowIndex) => {
|
||||||
|
if (row.separator !== undefined) {
|
||||||
|
return (
|
||||||
|
<tr key={rowIndex} className="separator-row">
|
||||||
|
<td className="hint-cell" />
|
||||||
|
{Array.from({ length: middle }, (_, i) => (
|
||||||
|
<td key={i} />
|
||||||
|
))}
|
||||||
|
<td className="letter-static separator-char">
|
||||||
|
{row.separator === ' ' ? '' : row.separator}
|
||||||
|
</td>
|
||||||
|
{Array.from({ length: width - middle }, (_, i) => (
|
||||||
|
<td key={middle + 1 + i} />
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<GameRow
|
<GameRow
|
||||||
key={rowIndex}
|
key={rowIndex}
|
||||||
actorName={row.actorName}
|
actorName={row.actorName}
|
||||||
pos={row.pos}
|
pos={row.pos}
|
||||||
colStart={middle - row.pos}
|
colStart={middle - row.pos}
|
||||||
totalWidth={width}
|
totalWidth={width}
|
||||||
|
hintType={row.hintType}
|
||||||
|
hintText={row.hintText}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import React, { useRef, useCallback } from 'react';
|
import React, { useRef, useCallback, useMemo } from 'react';
|
||||||
import LetterInput from './LetterInput';
|
import LetterInput from './LetterInput';
|
||||||
import ActorPopover from './ActorPopover';
|
import ActorPopover from './ActorPopover';
|
||||||
|
|
||||||
export default function GameRow({ actorName, pos, colStart, totalWidth }) {
|
function isLetter(ch) {
|
||||||
|
return /[a-zA-Z]/.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
|
||||||
const inputRefs = useRef([]);
|
const inputRefs = useRef([]);
|
||||||
|
const letters = actorName.split('');
|
||||||
|
|
||||||
|
const letterIndices = useMemo(
|
||||||
|
() => letters.reduce((acc, ch, i) => { if (isLetter(ch)) acc.push(i); return acc; }, []),
|
||||||
|
[actorName]
|
||||||
|
);
|
||||||
|
|
||||||
const setInputRef = useCallback((index) => (el) => {
|
const setInputRef = useCallback((index) => (el) => {
|
||||||
inputRefs.current[index] = el;
|
inputRefs.current[index] = el;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const focusInput = useCallback((index) => {
|
const focusNextInput = useCallback((charIndex, direction) => {
|
||||||
inputRefs.current[index]?.focus();
|
const currentPos = letterIndices.indexOf(charIndex);
|
||||||
}, []);
|
const nextPos = currentPos + direction;
|
||||||
|
if (nextPos >= 0 && nextPos < letterIndices.length) {
|
||||||
const letters = actorName.split('');
|
inputRefs.current[letterIndices[nextPos]]?.focus();
|
||||||
|
}
|
||||||
|
}, [letterIndices]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
|
<td className="hint-cell">
|
||||||
|
<ActorPopover hintType={hintType} hintText={hintText} />
|
||||||
|
</td>
|
||||||
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
|
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
|
||||||
const charIndex = colIndex - colStart;
|
const charIndex = colIndex - colStart;
|
||||||
const isInRange = charIndex >= 0 && charIndex < letters.length;
|
const isInRange = charIndex >= 0 && charIndex < letters.length;
|
||||||
@@ -25,19 +40,26 @@ export default function GameRow({ actorName, pos, colStart, totalWidth }) {
|
|||||||
return <td key={colIndex} />;
|
return <td key={colIndex} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ch = letters[charIndex];
|
||||||
|
|
||||||
|
if (!isLetter(ch)) {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="letter-static">
|
||||||
|
{ch}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LetterInput
|
<LetterInput
|
||||||
key={colIndex}
|
key={colIndex}
|
||||||
highlighted={charIndex === pos}
|
highlighted={charIndex === pos}
|
||||||
inputRef={setInputRef(charIndex)}
|
inputRef={setInputRef(charIndex)}
|
||||||
onNext={() => focusInput(charIndex + 1)}
|
onNext={() => focusNextInput(charIndex, 1)}
|
||||||
onPrev={() => focusInput(charIndex - 1)}
|
onPrev={() => focusNextInput(charIndex, -1)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<td>
|
|
||||||
<ActorPopover actorName={actorName} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +1,934 @@
|
|||||||
body {
|
/* ============================================================
|
||||||
background-color: skyblue;
|
SUNRISE — Lumineux, éditorial, chaleureux
|
||||||
font-family: 'Noto Sans', sans-serif;
|
============================================================ */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #fef6ee;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-warm: #fff7ed;
|
||||||
|
--surface-tint: #ffedd5;
|
||||||
|
--orange: #ea580c;
|
||||||
|
--orange-mid: #f97316;
|
||||||
|
--orange-light: #fed7aa;
|
||||||
|
--text: #1c1917;
|
||||||
|
--text-muted: #78716c;
|
||||||
|
--text-faint: #a8a29e;
|
||||||
|
--border: #e8d5c0;
|
||||||
|
--border-warm: #fdba74;
|
||||||
|
--shadow-warm: rgba(180, 100, 30, 0.08);
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--cell: 38px;
|
||||||
|
--cell-font: 15px;
|
||||||
|
--trigger-h: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
padding: 0 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game grid ── */
|
||||||
|
|
||||||
#actors {
|
#actors {
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
margin: 40px auto;
|
border-spacing: 5px;
|
||||||
|
margin: 56px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#actors td {
|
#actors td {
|
||||||
width: 32px;
|
width: var(--cell);
|
||||||
height: 32px;
|
height: var(--cell);
|
||||||
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.letter-input {
|
.letter-input {
|
||||||
width: 32px;
|
width: var(--cell);
|
||||||
height: 32px;
|
height: var(--cell);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 16px;
|
font-family: 'Inter', sans-serif;
|
||||||
font-weight: bold;
|
font-size: var(--cell-font);
|
||||||
border: 2px solid #d1d5db;
|
font-weight: 700;
|
||||||
border-radius: 4px;
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
|
box-shadow: 0 1px 3px var(--shadow-warm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.letter-input:focus {
|
.letter-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #2563eb;
|
border-color: var(--orange);
|
||||||
|
box-shadow: 0 0 0 3px rgba(234, 88, 12, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.letter-highlighted {
|
.letter-highlighted {
|
||||||
background-color: #fecaca;
|
background-color: var(--orange-light);
|
||||||
border-color: #dc2626;
|
border-color: var(--orange);
|
||||||
|
color: var(--orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.letter-static {
|
||||||
|
width: var(--cell);
|
||||||
|
height: var(--cell);
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: var(--cell-font);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator-row td {
|
||||||
|
height: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator-char {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Popover ── */
|
||||||
|
|
||||||
.popover-trigger {
|
.popover-trigger {
|
||||||
width: 28px;
|
width: var(--cell);
|
||||||
height: 28px;
|
height: var(--trigger-h);
|
||||||
border-radius: 50%;
|
border-radius: 6px;
|
||||||
border: 1px solid #d1d5db;
|
border: none;
|
||||||
background: white;
|
background: var(--orange);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #6b7280;
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
box-shadow: 0 1px 3px var(--shadow-warm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-trigger:hover {
|
.popover-trigger:hover {
|
||||||
background: #f3f4f6;
|
background: var(--orange-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actor-popover {
|
.actor-popover {
|
||||||
background: white;
|
background: var(--surface);
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
padding: 12px 16px;
|
padding: 10px 16px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 20px var(--shadow-warm);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Auth pages ── */
|
||||||
|
|
||||||
.auth-container {
|
.auth-container {
|
||||||
max-width: 400px;
|
max-width: 420px;
|
||||||
margin: 80px auto;
|
margin: 80px auto;
|
||||||
padding: 32px;
|
padding: 40px;
|
||||||
background: white;
|
background: var(--surface);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 4px 32px var(--shadow-warm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-container h1 {
|
.auth-container h1 {
|
||||||
margin: 0 0 24px;
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-container label {
|
.auth-container label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-container input[type="email"],
|
.auth-container input[type="email"],
|
||||||
.auth-container input[type="password"] {
|
.auth-container input[type="password"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 11px 14px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
border: 1px solid #ccc;
|
border: 1.5px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
box-sizing: border-box;
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: var(--surface-warm);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container input[type="email"]:focus,
|
||||||
|
.auth-container input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--orange);
|
||||||
|
box-shadow: 0 0 0 3px rgba(234, 88, 12, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-container button[type="submit"] {
|
.auth-container button[type="submit"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
background: #2563eb;
|
background: var(--orange);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 100px;
|
||||||
font-size: 16px;
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-container button[type="submit"]:hover {
|
.auth-container button[type="submit"]:hover {
|
||||||
background: #1d4ed8;
|
background: var(--orange-mid);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(234, 88, 12, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-error {
|
.auth-error {
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-link {
|
.auth-link {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 16px;
|
margin-top: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-link a {
|
.auth-link a {
|
||||||
color: #2563eb;
|
color: var(--orange);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Navbar ── */
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 40px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 0 var(--border), 0 2px 12px var(--shadow-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
color: var(--orange);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-prefix {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-left,
|
||||||
|
.navbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-icon:hover {
|
||||||
|
background: var(--surface-tint);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badge ── */
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Dropdown ── */
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 8px 32px var(--shadow-warm), 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
min-width: 210px;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--surface-tint);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-empty {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dropdown item row ── */
|
||||||
|
|
||||||
|
.dropdown-item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item-row .dropdown-item {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--text-faint);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-btn:hover {
|
||||||
|
border-color: var(--orange);
|
||||||
|
color: var(--orange);
|
||||||
|
background: var(--surface-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Help steps ── */
|
||||||
|
|
||||||
|
.help-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-steps code {
|
||||||
|
background: var(--surface-tint);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-steps a {
|
||||||
|
color: var(--orange);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-steps a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Import status ── */
|
||||||
|
|
||||||
|
.import-status-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-status-text {
|
||||||
|
display: block;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-status-active {
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-status-completed {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-status-failed {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ── */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body p {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 28px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 9px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--orange);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--orange-mid);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(234, 88, 12, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: var(--orange-light);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal feedback ── */
|
||||||
|
|
||||||
|
.modal-feedback {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-feedback.error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-feedback.success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game card ── */
|
||||||
|
|
||||||
|
.game-container {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 56px auto;
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 4px 32px var(--shadow-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-container #actors {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-cell {
|
||||||
|
padding: 0;
|
||||||
|
position: sticky;
|
||||||
|
left: 5px;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow:
|
||||||
|
-5px 0 0 var(--surface),
|
||||||
|
5px 0 0 var(--surface),
|
||||||
|
0 -5px 0 var(--surface),
|
||||||
|
0 5px 0 var(--surface),
|
||||||
|
-5px -5px 0 var(--surface),
|
||||||
|
5px -5px 0 var(--surface),
|
||||||
|
-5px 5px 0 var(--surface),
|
||||||
|
5px 5px 0 var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-grid-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game actions ── */
|
||||||
|
|
||||||
|
.game-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon {
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: none;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1.5px solid #dc2626;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon:hover {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abandon-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abandon-popover {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 4px 16px var(--shadow-warm);
|
||||||
|
z-index: 100;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abandon-popover.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abandon-popover-text {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abandon-popover-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon-confirm {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon-confirm:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon-cancel {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon-cancel:hover {
|
||||||
|
background: var(--surface-hover, #f5f5f5);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-start-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 64px - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-login-hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-login-hint a {
|
||||||
|
color: var(--orange);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-login-hint a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
padding: 14px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game config panel ── */
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section + .config-section {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
|
||||||
|
.config-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-toggle-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
appearance: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 100px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch:checked {
|
||||||
|
background: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch:checked::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hint type checkboxes */
|
||||||
|
|
||||||
|
.config-hint-types {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-checkbox input[type="checkbox"] {
|
||||||
|
accent-color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Award type list */
|
||||||
|
|
||||||
|
.config-award-types {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-award-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-award-list .config-checkbox {
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-award-list .config-checkbox:first-child {
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash messages */
|
||||||
|
|
||||||
|
.flash-error {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-loader {
|
||||||
|
display: none;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-top-color: #ff6b81;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game footer ── */
|
||||||
|
|
||||||
|
.game-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bug-report {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 100px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bug-report:hover {
|
||||||
|
color: var(--orange);
|
||||||
|
background: var(--surface-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive grid ── */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:root {
|
||||||
|
--cell: 30px;
|
||||||
|
--cell-font: 12px;
|
||||||
|
--trigger-h: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-container {
|
||||||
|
padding: 16px 16px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
:root {
|
||||||
|
--cell: 24px;
|
||||||
|
--cell-font: 10px;
|
||||||
|
--trigger-h: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#actors {
|
||||||
|
border-spacing: 3px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"doctrine/doctrine-bundle": "^3.2",
|
"doctrine/doctrine-bundle": "^3.2",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
|
"league/flysystem-aws-s3-v3": "*",
|
||||||
|
"league/flysystem-bundle": "*",
|
||||||
"pentatrion/vite-bundle": "^8.2",
|
"pentatrion/vite-bundle": "^8.2",
|
||||||
"phpdocumentor/reflection-docblock": "^6.0",
|
"phpdocumentor/reflection-docblock": "^6.0",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
|
|||||||
1062
composer.lock
generated
1062
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -15,4 +15,5 @@ return [
|
|||||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
Symfony\UX\React\ReactBundle::class => ['all' => true],
|
Symfony\UX\React\ReactBundle::class => ['all' => true],
|
||||||
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
|
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
|
||||||
|
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ doctrine:
|
|||||||
numeric_functions:
|
numeric_functions:
|
||||||
Random: App\Doctrine\Extension\Random
|
Random: App\Doctrine\Extension\Random
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
dbname: app_test
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
orm:
|
orm:
|
||||||
|
|||||||
19
config/packages/flysystem.yaml
Normal file
19
config/packages/flysystem.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
flysystem:
|
||||||
|
storages:
|
||||||
|
default.storage:
|
||||||
|
adapter: 'aws'
|
||||||
|
options:
|
||||||
|
client: 's3_client'
|
||||||
|
bucket: 'ltbxd-actorle'
|
||||||
|
|
||||||
|
services:
|
||||||
|
s3_client:
|
||||||
|
class: Aws\S3\S3Client
|
||||||
|
arguments:
|
||||||
|
- endpoint: 'https://s3.lclr.dev'
|
||||||
|
credentials:
|
||||||
|
key: '%env(S3_ACCESS_KEY)%'
|
||||||
|
secret: '%env(S3_SECRET_KEY)%'
|
||||||
|
region: 'us-east-1'
|
||||||
|
version: 'latest'
|
||||||
|
use_path_style_endpoint: true
|
||||||
@@ -25,5 +25,5 @@ framework:
|
|||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
|
|
||||||
# Route your messages to the transports
|
App\Message\ProcessImportMessage: async
|
||||||
# 'App\Message\YourMessage': async
|
App\Message\ImportFilmsBatchMessage: async
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ security:
|
|||||||
access_control:
|
access_control:
|
||||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/register, roles: PUBLIC_ACCESS }
|
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/game, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/, roles: ROLE_USER }
|
- { path: ^/, roles: ROLE_USER }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
|
|||||||
@@ -1487,6 +1487,22 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* preload_attributes?: list<scalar|null|Param>,
|
* preload_attributes?: list<scalar|null|Param>,
|
||||||
* }>,
|
* }>,
|
||||||
* }
|
* }
|
||||||
|
* @psalm-type FlysystemConfig = array{
|
||||||
|
* storages?: array<string, array{ // Default: []
|
||||||
|
* adapter: scalar|null|Param,
|
||||||
|
* options?: list<mixed>,
|
||||||
|
* visibility?: scalar|null|Param, // Default: null
|
||||||
|
* directory_visibility?: scalar|null|Param, // Default: null
|
||||||
|
* retain_visibility?: bool|null|Param, // Default: null
|
||||||
|
* case_sensitive?: bool|Param, // Default: true
|
||||||
|
* disable_asserts?: bool|Param, // Default: false
|
||||||
|
* public_url?: list<scalar|null|Param>,
|
||||||
|
* path_normalizer?: scalar|null|Param, // Default: null
|
||||||
|
* public_url_generator?: scalar|null|Param, // Default: null
|
||||||
|
* temporary_url_generator?: scalar|null|Param, // Default: null
|
||||||
|
* read_only?: bool|Param, // Default: false
|
||||||
|
* }>,
|
||||||
|
* }
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1502,6 +1518,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1520,6 +1537,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* maker?: MakerConfig,
|
* maker?: MakerConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1536,6 +1554,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1553,6 +1572,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
3
config/secrets/dev/dev.S3_ACCESS_KEY.a6ceee.php
Normal file
3
config/secrets/dev/dev.S3_ACCESS_KEY.a6ceee.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php // dev.S3_ACCESS_KEY.a6ceee on Sun, 29 Mar 2026 21:16:43 +0000
|
||||||
|
|
||||||
|
return "\xCD1\x7D\xED\xEF\x168\x0D\xC5e\x8B\x24~\x85\x04\xBB1P\x11\x7C\x29w\xCC\x1As\x3A\xE7e\x82\xB2Pi\xD1\xA6_\x82RL0R\xE1\xF2\x0Af\x86\xB9\x12\x8B9\xDEs\xD0Kn\xE8p\x84\x02\xAC5\x26\xE5\xC2\xB1\x93\x3B\xF2b";
|
||||||
3
config/secrets/dev/dev.S3_SECRET_KEY.7b8691.php
Normal file
3
config/secrets/dev/dev.S3_SECRET_KEY.7b8691.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php // dev.S3_SECRET_KEY.7b8691 on Sun, 29 Mar 2026 21:17:09 +0000
|
||||||
|
|
||||||
|
return "\xDCA\x5B\x1E\x7D\x83\x21\x02\xC4\xB4WiX\x8C\x26\xFA\x21\xABk\x85\xB0\x0A\x23e\x05\x81LM\x17\x03\xBA\x01e\xE3\xB8~\x84\xC45\x9E\x2F\x2A\xBB1\x9C\xA1\xC6x.\x2AF\x80\xA7\xC8\xEASA\xB0A\xA8F\x14\xED\xFC\xF6\x00\x8Bp\xD1\x25\x19\x2A-\x80\xBA\xD75\xEE\xFA\xD2\xB1H\x7Ba\x180\xFAU";
|
||||||
3
config/secrets/dev/dev.TMDB_API_TOKEN.453a96.php
Normal file
3
config/secrets/dev/dev.TMDB_API_TOKEN.453a96.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php // dev.TMDB_API_TOKEN.453a96 on Sun, 29 Mar 2026 21:52:05 +0000
|
||||||
|
|
||||||
|
return "\xB3\x86\xD7\x92T\xB0\xB6\xCD\x28\x04\x12\x1Es\x17J0\xEB\xF3\x94-\xC9\x88\xDF\xBA\xCCKX\xDB\x3E\x9E52\xFA\x3At\xEF\x022\x2F\xCA\xCDC\x89\xDF\x89\x3B\x04\x3F\xC59\x9D\x0C\xFCp\xB0H\x90s-\xAB\x87\xAAN4-2\x8Aj\xA7C\x19\xAF\xCF\x7DOLvM\xEFk\xE3\x7D\x40\x83~\xB5~\x29Q\x8Di\x18c\x06\x7CH\x85DU\xF4\xCB\xAA\x0B\x3B\x8D\x82n\x0Ch\x1A\xB1.\xAB\xF0\xF2\x23\x12\x93\xAB\x91\xCF\x91qU\x60\x09n\xAD\x1B\x14\xFFu\x00d6\x20\x83\xACm\x9FvQ\x80\xADJd\xF5v\x85\x09\xD5\x88\xCDq\xFFh\xE1\x80\x29\x5D\xAA9\x16\x2A\x3B\x2F\xF6.\xBE\x85\xB21\xB5\xDF\xB0\x93K\x90\x9E\xA4n\x85n0S2\xB7\xE6R\xA9\x8A\x93\xE7\x22\x9C\xCB\x7D\xDD0\xAB\xB4\x10W\x07\x3AxSatz\x5D\x04\x5DN-\xF06\x19\x1A\x83\x9C\x84\x8DXm\x7C\xF4\x03\xB7-\xE4\xBFp\xCBy7\xD8\xA8\x09\xCAK\x9A\xA3\xB8\xFF-js\xB0\xB3\x8AZ\xD3\x85Q\xE2\xBD.\xCF\xEB\x8E\xAC\xDE\xF9\x83\x0E\x80\xD0m\x00\x89\xF7\xB2\xE3\xA7\xD8\xD8\xA8\x92\x160\x92f\xBC\xAF4\xA1\x8Bu\xAD\xBD\xE8";
|
||||||
3
config/secrets/dev/dev.encrypt.public.php
Normal file
3
config/secrets/dev/dev.encrypt.public.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php // dev.encrypt.public on Sun, 29 Mar 2026 08:40:15 +0000
|
||||||
|
|
||||||
|
return "\x94\xCEhu\xF8_6\x81\xB7\xEC\xE2Z\xCE\xC6H\xFBN\xAA\x13H\xBD\x15W\xD9\x80\x15\x0D\x40\xE4\x7F\xB2\x02";
|
||||||
7
config/secrets/dev/dev.list.php
Normal file
7
config/secrets/dev/dev.list.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'S3_ACCESS_KEY' => null,
|
||||||
|
'S3_SECRET_KEY' => null,
|
||||||
|
'TMDB_API_TOKEN' => null,
|
||||||
|
];
|
||||||
194
designs/sunrise-charte.md
Normal file
194
designs/sunrise-charte.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Charte graphique — Sunrise
|
||||||
|
|
||||||
|
Thème lumineux, éditorial, chaleureux. Inspiré des tons ambrés du petit matin.
|
||||||
|
Sobre sans être froid, accessible sans être infantile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Couleurs
|
||||||
|
|
||||||
|
### Palette principale
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|---------------|-----------|--------------------------------------------|
|
||||||
|
| `--bg` | `#fef6ee` | Fond de page — crème orangé très doux |
|
||||||
|
| `--surface` | `#ffffff` | Cartes, navbar, modals |
|
||||||
|
| `--surface-warm` | `#fff7ed` | Fond de champ input, surfaces secondaires |
|
||||||
|
| `--surface-tint` | `#ffedd5` | Hover sur items de dropdown, fond actif |
|
||||||
|
|
||||||
|
### Orange
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|------------------|-----------|-------------------------------------------------|
|
||||||
|
| `--orange` | `#ea580c` | Couleur principale — boutons, liens, logo |
|
||||||
|
| `--orange-mid` | `#f97316` | Hover sur boutons primaires |
|
||||||
|
| `--orange-light` | `#fed7aa` | Fond cellule surlignée (grille de jeu) |
|
||||||
|
| `--orange-pale` | `#fff7ed` | Fond très pâle — identique à `--surface-warm` |
|
||||||
|
|
||||||
|
> La couleur d'accentuation unique est l'orange `#ea580c` (orange-600 Tailwind).
|
||||||
|
> Ne pas introduire de seconde couleur d'accentuation.
|
||||||
|
|
||||||
|
### Texte
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-----------------|-----------|-------------------------------------------|
|
||||||
|
| `--text` | `#1c1917` | Corps de texte, labels, titres |
|
||||||
|
| `--text-muted` | `#78716c` | Texte secondaire, descriptions |
|
||||||
|
| `--text-faint` | `#a8a29e` | Placeholders, métadonnées, separateurs |
|
||||||
|
|
||||||
|
### Bordures et ombres
|
||||||
|
|
||||||
|
| Token | Valeur | Usage |
|
||||||
|
|------------------|---------------------------------|-----------------------------------|
|
||||||
|
| `--border` | `#e8d5c0` | Bordures standard — chaudes |
|
||||||
|
| `--border-warm` | `#fdba74` | Bordure cellule active (grille) |
|
||||||
|
| `--shadow-warm` | `rgba(180, 100, 30, 0.08)` | Ombre douce sur cartes et navbar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typographie
|
||||||
|
|
||||||
|
### Polices
|
||||||
|
|
||||||
|
| Rôle | Famille | Google Fonts |
|
||||||
|
|---------------|------------------|-------------------------------------|
|
||||||
|
| Titres / Logo | **Fraunces** | Sérif optique, poids 400 et 700 |
|
||||||
|
| Corps | **Inter** | Sans-serif, poids 400, 500, 600, 700 |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,700;1,9..144,400&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hiérarchie
|
||||||
|
|
||||||
|
| Niveau | Police | Taille | Poids | Utilisation |
|
||||||
|
|------------|------------|--------|-------|--------------------------------------|
|
||||||
|
| H1 page | Fraunces | 26px | 700 | Titre de page (login, register) |
|
||||||
|
| H2 modal | Inter | 18px | 700 | Titres de modals |
|
||||||
|
| Logo | Fraunces | 22px | 700 | Navbar brand |
|
||||||
|
| Corps | Inter | 15px | 400 | Texte courant, inputs |
|
||||||
|
| Label | Inter | 13px | 600 | Labels de formulaire |
|
||||||
|
| Secondaire | Inter | 14px | 400 | Items dropdown, descriptions |
|
||||||
|
| Micro | Inter | 11–12px| 600 | Badges, headers dropdown, métadonnées|
|
||||||
|
|
||||||
|
### Règles typographiques
|
||||||
|
|
||||||
|
- Titres Fraunces : `letter-spacing: -0.3px` pour un rendu serré et élégant
|
||||||
|
- Pas de `text-transform: uppercase` sauf pour des labels discrets (taille ≤ 12px, spacing ≥ 0.5px)
|
||||||
|
- Interlignage corps : `1.5` minimum
|
||||||
|
- Taille minimale affichée : `11px`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rayons (Border radius)
|
||||||
|
|
||||||
|
| Token | Valeur | Usage |
|
||||||
|
|---------------|--------|----------------------------------------------|
|
||||||
|
| `--radius-sm` | `6px` | Inputs, cellules grille, petits éléments |
|
||||||
|
| `--radius-md` | `10px` | Dropdowns, popovers, tooltips |
|
||||||
|
| `--radius-lg` | `18px` | Cartes auth, modals |
|
||||||
|
| Pilule | `100px`| Bouton primaire, bouton submit |
|
||||||
|
|
||||||
|
> Le bouton primaire utilise systématiquement `border-radius: 100px` (forme pilule).
|
||||||
|
> Les champs de saisie utilisent `--radius-sm` (6px), jamais la pilule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composants
|
||||||
|
|
||||||
|
### Bouton primaire
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: #ea580c;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 9px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hover :** `background: #f97316` + `transform: translateY(-1px)` + `box-shadow: 0 4px 16px rgba(234,88,12,0.25)`
|
||||||
|
|
||||||
|
### Navbar
|
||||||
|
|
||||||
|
- Hauteur : `64px`
|
||||||
|
- Fond : `#ffffff`
|
||||||
|
- Bordure basse : `1px solid #e8d5c0`
|
||||||
|
- Ombre : `0 2px 12px rgba(180,100,30,0.08)`
|
||||||
|
- Padding horizontal : `40px`
|
||||||
|
- Logo (Fraunces, 22px, `#ea580c`) à gauche
|
||||||
|
- Actions à droite — icônes rondes (`border-radius: 50%`), hover sur fond `--surface-tint`
|
||||||
|
|
||||||
|
### Champ de formulaire
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1.5px solid #e8d5c0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Focus :** `border-color: #ea580c` + `box-shadow: 0 0 0 3px rgba(234,88,12,0.12)`
|
||||||
|
|
||||||
|
### Cellule de grille (lettre)
|
||||||
|
|
||||||
|
- Taille : `38 × 38px`
|
||||||
|
- Fond normal : `#ffffff`, bordure `#e8d5c0`
|
||||||
|
- Fond rempli : `#fff7ed`, bordure `#fdba74`
|
||||||
|
- **Surlignée** (lettre cible) : fond `#fed7aa`, bordure `#ea580c`, texte `#ea580c`
|
||||||
|
- Focus : bordure `#ea580c` + ring `rgba(234,88,12,0.15)`
|
||||||
|
- Espacement entre cellules (`border-spacing`) : `5px`
|
||||||
|
|
||||||
|
### Dropdown
|
||||||
|
|
||||||
|
- Fond : `#ffffff`
|
||||||
|
- Bordure : `1px solid #e8d5c0`
|
||||||
|
- Rayon : `10px`
|
||||||
|
- Ombre : `0 8px 32px rgba(180,100,30,0.08), 0 2px 8px rgba(0,0,0,0.06)`
|
||||||
|
- Item hover : fond `#ffedd5`, couleur `#ea580c`
|
||||||
|
- Header : `11px`, uppercase, `#a8a29e`, séparateur bas
|
||||||
|
|
||||||
|
### Badge notification
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: #ea580c;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal
|
||||||
|
|
||||||
|
- Fond : `#ffffff`
|
||||||
|
- Rayon : `12px` (légèrement plus petit que `--radius-lg` pour la hiérarchie visuelle)
|
||||||
|
- Ombre : `0 8px 24px rgba(0,0,0,0.12)`
|
||||||
|
- Overlay : `rgba(0,0,0,0.4)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ombres
|
||||||
|
|
||||||
|
Toutes les ombres sont **chaudes** (teinte orange-brun), jamais neutres grises.
|
||||||
|
|
||||||
|
| Niveau | Valeur CSS |
|
||||||
|
|--------------|------------------------------------------------------------------|
|
||||||
|
| Légère | `0 1px 3px rgba(180,100,30,0.08)` |
|
||||||
|
| Navbar | `0 2px 12px rgba(180,100,30,0.08)` |
|
||||||
|
| Carte | `0 4px 32px rgba(180,100,30,0.08)` |
|
||||||
|
| Dropdown | `0 8px 32px rgba(180,100,30,0.08), 0 2px 8px rgba(0,0,0,0.06)` |
|
||||||
|
| Bouton hover | `0 4px 16px rgba(234,88,12,0.25)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles d'usage
|
||||||
|
|
||||||
|
1. **Orange réservé à l'accentuation.** Ne pas l'utiliser pour du texte courant de longue lecture.
|
||||||
|
2. **Fond de page ≠ blanc.** Toujours `#fef6ee` pour le fond, `#ffffff` pour les surfaces élevées (cartes, navbar).
|
||||||
|
3. **Ombres chaudes uniquement.** Aucune ombre `rgba(0,0,0,X)` seule sur des surfaces visibles — toujours teintée.
|
||||||
|
4. **Bouton primaire = pilule.** Boutons destructifs ou secondaires peuvent utiliser `--radius-sm`.
|
||||||
|
5. **Fraunces uniquement pour les titres de page et le logo.** Pas dans le corps, pas dans les labels.
|
||||||
|
6. **Pas de couleur rouge** pour les erreurs — utiliser `#b45309` (ambre foncé) ou `#dc2626` uniquement pour les messages d'erreur critiques, jamais comme couleur d'interface.
|
||||||
515
designs/sunrise.html
Normal file
515
designs/sunrise.html
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Actorle — Design "Sunrise" (lumineux, éditorial)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,700;1,9..144,400&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* ============================================================
|
||||||
|
SUNRISE — Lumineux, éditorial, chaleureux
|
||||||
|
Typographie sérif pour le titre, sans-serif pour le corps
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #fef6ee;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-warm: #fff7ed;
|
||||||
|
--surface-tint: #ffedd5;
|
||||||
|
--orange: #ea580c;
|
||||||
|
--orange-mid: #f97316;
|
||||||
|
--orange-light: #fed7aa;
|
||||||
|
--orange-pale: #fff7ed;
|
||||||
|
--text: #1c1917;
|
||||||
|
--text-muted: #78716c;
|
||||||
|
--text-faint: #a8a29e;
|
||||||
|
--border: #e8d5c0;
|
||||||
|
--border-warm: #fdba74;
|
||||||
|
--shadow-warm: rgba(180, 100, 30, 0.08);
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Navbar ── */
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 40px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 0 var(--border), 0 2px 12px var(--shadow-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
color: var(--orange);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.navbar-icon:hover {
|
||||||
|
background: var(--surface-tint);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 9px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--orange);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--orange-mid);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(234,88,12,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game grid ── */
|
||||||
|
.game-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 56px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.game-header em {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
table#actors {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#actors td {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-input {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
box-shadow: 0 1px 3px var(--shadow-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--orange);
|
||||||
|
box-shadow: 0 0 0 3px rgba(234,88,12,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-input.filled {
|
||||||
|
background: var(--surface-warm);
|
||||||
|
border-color: var(--border-warm);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-highlighted {
|
||||||
|
background: var(--orange-light) !important;
|
||||||
|
border-color: var(--orange) !important;
|
||||||
|
color: var(--orange) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Popover ── */
|
||||||
|
.popover-trigger {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
box-shadow: 0 1px 3px var(--shadow-warm);
|
||||||
|
}
|
||||||
|
.popover-trigger:hover {
|
||||||
|
border-color: var(--orange);
|
||||||
|
color: var(--orange);
|
||||||
|
background: var(--surface-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-popover {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 16px;
|
||||||
|
box-shadow: 0 4px 20px var(--shadow-warm);
|
||||||
|
z-index: 100;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Auth ── */
|
||||||
|
.page-auth {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 56px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 4px 32px var(--shadow-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container h1 {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container input[type="email"],
|
||||||
|
.auth-container input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 15px;
|
||||||
|
background: var(--surface-warm);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.auth-container input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--orange);
|
||||||
|
box-shadow: 0 0 0 3px rgba(234,88,12,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container button[type="submit"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--orange);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.auth-container button[type="submit"]:hover {
|
||||||
|
background: var(--orange-mid);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(234,88,12,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 20px 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.auth-divider::before, .auth-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-link a { color: var(--orange); text-decoration: none; font-weight: 600; }
|
||||||
|
.auth-link a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Dropdown ── */
|
||||||
|
.navbar-item { position: relative; }
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 8px 32px var(--shadow-warm), 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
min-width: 210px;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.12s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dropdown-item:hover { background: var(--surface-tint); color: var(--orange); }
|
||||||
|
|
||||||
|
/* ── Badge ── */
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px; right: 2px;
|
||||||
|
background: var(--orange);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo helpers ── */
|
||||||
|
.demo-section { margin: 40px auto; max-width: 900px; padding: 0 24px; }
|
||||||
|
.demo-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- NAVBAR — Connecté -->
|
||||||
|
<nav class="navbar">
|
||||||
|
<a href="#" class="navbar-brand">Actorle</a>
|
||||||
|
<div class="navbar-right">
|
||||||
|
<div class="navbar-item">
|
||||||
|
<button class="navbar-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
<span class="badge">3</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-item">
|
||||||
|
<button class="navbar-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- NAVBAR — Non connecté -->
|
||||||
|
<div class="demo-section" style="margin-top:24px;">
|
||||||
|
<div class="demo-label">Navbar — non connecté</div>
|
||||||
|
<nav class="navbar" style="position:relative;">
|
||||||
|
<a href="#" class="navbar-brand">Actorle</a>
|
||||||
|
<div class="navbar-right">
|
||||||
|
<a href="#" class="btn btn-primary">Se connecter</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DROPDOWN (toujours visible en preview) -->
|
||||||
|
<div class="demo-section">
|
||||||
|
<div class="demo-label">Menu utilisateur</div>
|
||||||
|
<div class="dropdown-menu" style="position:relative; display:block; top:auto;">
|
||||||
|
<div class="dropdown-header">Compte</div>
|
||||||
|
<button class="dropdown-item">Importer ses films</button>
|
||||||
|
<a href="#" class="dropdown-item">Se déconnecter</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GAME GRID -->
|
||||||
|
<div class="demo-section">
|
||||||
|
<div class="demo-label">Grille de jeu</div>
|
||||||
|
<div class="game-wrapper" style="padding:24px 0;">
|
||||||
|
<p class="game-header">Trouvez l'acteur — <em>4 indices</em></p>
|
||||||
|
<table id="actors">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><input class="letter-input letter-highlighted" maxlength="1" value="M" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="A" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="R" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="G" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="O" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="T" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><button class="popover-trigger">?</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="B" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="R" readonly></td>
|
||||||
|
<td><input class="letter-input letter-highlighted" maxlength="1" value="A" readonly></td>
|
||||||
|
<td><input class="letter-input filled" maxlength="1" value="D" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><button class="popover-trigger">?</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input letter-highlighted" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><button class="popover-trigger">?</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input letter-highlighted" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><input class="letter-input" maxlength="1" readonly></td>
|
||||||
|
<td><button class="popover-trigger">?</button></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AUTH FORM -->
|
||||||
|
<div class="demo-section">
|
||||||
|
<div class="demo-label">Formulaire de connexion</div>
|
||||||
|
<div class="auth-container">
|
||||||
|
<h1>Bon retour</h1>
|
||||||
|
<p class="auth-subtitle">Connectez-vous pour suivre vos scores</p>
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" placeholder="vous@exemple.fr">
|
||||||
|
<label>Mot de passe</label>
|
||||||
|
<input type="password" placeholder="••••••••">
|
||||||
|
<button type="submit">Connexion</button>
|
||||||
|
<div class="auth-link" style="margin-top:16px;">
|
||||||
|
Pas encore de compte ? <a href="#">S'inscrire</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:60px;"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,9 +8,13 @@ services:
|
|||||||
APP_ENV: dev
|
APP_ENV: dev
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- vendor:/app/vendor
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "${PORT_80:-80}:80"
|
||||||
|
|
||||||
|
messenger:
|
||||||
|
command: ["php", "-d", "memory_limit=512M", "bin/console", "messenger:consume", "async", "--time-limit=3600", "--memory-limit=256M", "-vv", "--no-debug"]
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
|
||||||
database:
|
database:
|
||||||
ports:
|
ports:
|
||||||
@@ -24,13 +28,11 @@ services:
|
|||||||
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:latest
|
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:latest
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- vendor:/app/vendor
|
|
||||||
- node_modules:/app/node_modules
|
- node_modules:/app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "${PORT_5173:-5173}:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
vendor:
|
|
||||||
node_modules:
|
node_modules:
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ services:
|
|||||||
node-build: docker/node
|
node-build: docker/node
|
||||||
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest
|
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "${PORT_80:-80}:80"
|
||||||
- "443:443"
|
- "${PORT_443:-443}:443"
|
||||||
- "443:443/udp"
|
- "${PORT_443:-443}:443/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
@@ -18,6 +18,14 @@ services:
|
|||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
messenger:
|
||||||
|
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest
|
||||||
|
command: ["php", "bin/console", "messenger:consume", "async", "--time-limit=3600", "--memory-limit=256M", "-vv"]
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
database:
|
database:
|
||||||
build:
|
build:
|
||||||
context: docker/database
|
context: docker/database
|
||||||
|
|||||||
2063
docs/superpowers/plans/2026-03-29-user-import-notifications.md
Normal file
2063
docs/superpowers/plans/2026-03-29-user-import-notifications.md
Normal file
File diff suppressed because it is too large
Load Diff
571
docs/superpowers/plans/2026-03-30-game-hints.md
Normal file
571
docs/superpowers/plans/2026-03-30-game-hints.md
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
# Game Hints System Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Each game row displays a typed hint (film, character, award) about the main actor, replacing the "?" popover button with a type-specific icon.
|
||||||
|
|
||||||
|
**Architecture:** Two new columns (`hint_type`, `hint_data`) on `GameRow`. Hints are generated at game creation time in `GameGridGenerator`. A new `WikidataAwardGateway` service fetches awards via SPARQL. The React `ActorPopover` component shows a type icon instead of "?" and displays the resolved hint text in the popover.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 8 / Doctrine ORM (backend), React 19 (frontend), Wikidata SPARQL API (awards), Font Awesome (icons)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `hintType` and `hintData` columns to `GameRow` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/GameRow.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add properties and mapping to `GameRow`**
|
||||||
|
|
||||||
|
Add after the `$rowOrder` property (line 30):
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private ?string $hintType = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $hintData = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add getters and setters after `setRowOrder()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getHintType(): ?string
|
||||||
|
{
|
||||||
|
return $this->hintType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHintType(string $hintType): static
|
||||||
|
{
|
||||||
|
$this->hintType = $hintType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHintData(): ?string
|
||||||
|
{
|
||||||
|
return $this->hintData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHintData(string $hintData): static
|
||||||
|
{
|
||||||
|
$this->hintData = $hintData;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Generate and run migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console doctrine:migrations:diff
|
||||||
|
php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: new migration adding `hint_type` and `hint_data` columns to `game_row`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/GameRow.php migrations/
|
||||||
|
git commit -m "feat: add hintType and hintData columns to GameRow entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add `MovieRoleRepository::findOneRandomByActor()` method
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Repository/MovieRoleRepository.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the repository method**
|
||||||
|
|
||||||
|
Replace the commented-out methods with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param list<int> $excludeMovieRoleIds MovieRole IDs to exclude
|
||||||
|
* @return MovieRole|null
|
||||||
|
*/
|
||||||
|
public function findOneRandomByActor(int $actorId, array $excludeMovieRoleIds = []): ?MovieRole
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('mr')
|
||||||
|
->andWhere('mr.actor = :actorId')
|
||||||
|
->setParameter('actorId', $actorId);
|
||||||
|
|
||||||
|
if (!empty($excludeMovieRoleIds)) {
|
||||||
|
$qb->andWhere('mr.id NOT IN (:excludeIds)')
|
||||||
|
->setParameter('excludeIds', $excludeMovieRoleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb
|
||||||
|
->orderBy('RANDOM()')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Repository/MovieRoleRepository.php
|
||||||
|
git commit -m "feat: add findOneRandomByActor to MovieRoleRepository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `WikidataAwardGateway` service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/WikidataAwardGateway.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the gateway service**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class WikidataAwardGateway
|
||||||
|
{
|
||||||
|
private const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch awards for an actor from Wikidata.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, year: int}>
|
||||||
|
*/
|
||||||
|
public function getAwards(Actor $actor): array
|
||||||
|
{
|
||||||
|
$sparql = $this->buildQuery($actor->getName());
|
||||||
|
|
||||||
|
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
|
||||||
|
'query' => [
|
||||||
|
'query' => $sparql,
|
||||||
|
'format' => 'json',
|
||||||
|
],
|
||||||
|
'headers' => [
|
||||||
|
'Accept' => 'application/sparql-results+json',
|
||||||
|
'User-Agent' => 'LtbxdActorle/1.0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
$awards = [];
|
||||||
|
|
||||||
|
foreach ($data['results']['bindings'] ?? [] as $binding) {
|
||||||
|
$name = $binding['awardLabel']['value'] ?? null;
|
||||||
|
$year = $binding['year']['value'] ?? null;
|
||||||
|
|
||||||
|
if ($name && $year) {
|
||||||
|
$awards[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'year' => (int) substr($year, 0, 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $awards;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildQuery(string $actorName): string
|
||||||
|
{
|
||||||
|
$escaped = str_replace('"', '\\"', $actorName);
|
||||||
|
|
||||||
|
return <<<SPARQL
|
||||||
|
SELECT ?awardLabel ?year WHERE {
|
||||||
|
?person rdfs:label "{$escaped}"@en .
|
||||||
|
?person wdt:P31 wd:Q5 .
|
||||||
|
?person p:P166 ?awardStatement .
|
||||||
|
?awardStatement ps:P166 ?award .
|
||||||
|
?awardStatement pq:P585 ?date .
|
||||||
|
BIND(YEAR(?date) AS ?year)
|
||||||
|
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
|
||||||
|
}
|
||||||
|
ORDER BY DESC(?year)
|
||||||
|
SPARQL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the service is autowired**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console debug:container WikidataAwardGateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: service `App\Service\WikidataAwardGateway` is listed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/WikidataAwardGateway.php
|
||||||
|
git commit -m "feat: add WikidataAwardGateway for actor awards from Wikidata SPARQL"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Integrate hint generation into `GameGridGenerator`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/GameGridGenerator.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add dependencies to constructor**
|
||||||
|
|
||||||
|
Update the constructor at line 16-19:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly MovieRoleRepository $movieRoleRepository,
|
||||||
|
private readonly WikidataAwardGateway $wikidataAwardGateway,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import at the top:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Repository\MovieRoleRepository;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add hint generation methods**
|
||||||
|
|
||||||
|
Add these private methods after `computeGridData()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param list<int> $usedMovieRoleIds MovieRole IDs already used (for DB exclusion)
|
||||||
|
* @param list<string> $usedHintKeys Semantic keys like "film:42" to avoid duplicate hints
|
||||||
|
* @return array{type: string, data: string}|null
|
||||||
|
*/
|
||||||
|
private function generateHint(Actor $mainActor, array &$usedMovieRoleIds, array &$usedHintKeys): ?array
|
||||||
|
{
|
||||||
|
$types = ['film', 'character', 'award'];
|
||||||
|
shuffle($types);
|
||||||
|
|
||||||
|
foreach ($types as $type) {
|
||||||
|
$hint = $this->resolveHint($type, $mainActor, $usedMovieRoleIds, $usedHintKeys);
|
||||||
|
if ($hint !== null) {
|
||||||
|
return $hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $usedMovieRoleIds
|
||||||
|
* @param list<string> $usedHintKeys
|
||||||
|
* @return array{type: string, data: string}|null
|
||||||
|
*/
|
||||||
|
private function resolveHint(string $type, Actor $mainActor, array &$usedMovieRoleIds, array &$usedHintKeys): ?array
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case 'film':
|
||||||
|
$role = $this->movieRoleRepository->findOneRandomByActor(
|
||||||
|
$mainActor->getId(),
|
||||||
|
$usedMovieRoleIds,
|
||||||
|
);
|
||||||
|
if ($role === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$movieId = (string) $role->getMovie()->getId();
|
||||||
|
$key = 'film:' . $movieId;
|
||||||
|
if (in_array($key, $usedHintKeys)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$usedMovieRoleIds[] = $role->getId();
|
||||||
|
$usedHintKeys[] = $key;
|
||||||
|
return ['type' => 'film', 'data' => $movieId];
|
||||||
|
|
||||||
|
case 'character':
|
||||||
|
$role = $this->movieRoleRepository->findOneRandomByActor(
|
||||||
|
$mainActor->getId(),
|
||||||
|
$usedMovieRoleIds,
|
||||||
|
);
|
||||||
|
if ($role === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$roleId = (string) $role->getId();
|
||||||
|
$key = 'character:' . $roleId;
|
||||||
|
if (in_array($key, $usedHintKeys)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$usedMovieRoleIds[] = $role->getId();
|
||||||
|
$usedHintKeys[] = $key;
|
||||||
|
return ['type' => 'character', 'data' => $roleId];
|
||||||
|
|
||||||
|
case 'award':
|
||||||
|
try {
|
||||||
|
$awards = $this->wikidataAwardGateway->getAwards($mainActor);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($awards as $award) {
|
||||||
|
$text = $award['name'] . ' (' . $award['year'] . ')';
|
||||||
|
$key = 'award:' . $text;
|
||||||
|
if (!in_array($key, $usedHintKeys)) {
|
||||||
|
$usedHintKeys[] = $key;
|
||||||
|
return ['type' => 'award', 'data' => $text];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire hint generation into `generate()`**
|
||||||
|
|
||||||
|
Add `$usedMovieRoleIds = [];` and `$usedHintKeys = [];` after `$rowOrder = 0;` (line 31), and add hint assignment after `$row->setRowOrder($rowOrder);` (line 51):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$hint = $this->generateHint($mainActor, $usedMovieRoleIds, $usedHintKeys);
|
||||||
|
if ($hint !== null) {
|
||||||
|
$row->setHintType($hint['type']);
|
||||||
|
$row->setHintData($hint['data']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/GameGridGenerator.php
|
||||||
|
git commit -m "feat: generate hints per row in GameGridGenerator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Resolve hint display text in `computeGridData()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/GameGridGenerator.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add repositories needed for resolution**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Repository\MovieRepository;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update constructor:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly MovieRoleRepository $movieRoleRepository,
|
||||||
|
private readonly MovieRepository $movieRepository,
|
||||||
|
private readonly WikidataAwardGateway $wikidataAwardGateway,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add hint data to grid output in `computeGridData()`**
|
||||||
|
|
||||||
|
In the `computeGridData()` method, update the `$grid[]` assignment (around line 105-109) to include hint data:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$hintText = $this->resolveHintText($row);
|
||||||
|
|
||||||
|
$grid[] = [
|
||||||
|
'actorName' => $actor->getName(),
|
||||||
|
'actorId' => $actor->getId(),
|
||||||
|
'pos' => $pos,
|
||||||
|
'hintType' => $row->getHintType(),
|
||||||
|
'hintText' => $hintText,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `resolveHintText()` method**
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function resolveHintText(GameRow $row): ?string
|
||||||
|
{
|
||||||
|
$type = $row->getHintType();
|
||||||
|
$data = $row->getHintData();
|
||||||
|
|
||||||
|
if ($type === null || $data === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'film' => $this->movieRepository->find((int) $data)?->getTitle(),
|
||||||
|
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
|
||||||
|
'award' => $data,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the return type docblock**
|
||||||
|
|
||||||
|
Update the `@return` annotation of `computeGridData()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Compute display data (grid, width, middle) from a Game entity for the React component.
|
||||||
|
*
|
||||||
|
* @return array{grid: list<array{actorName: string, actorId: int, pos: int, hintType: ?string, hintText: ?string}>, width: int, middle: int}
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/GameGridGenerator.php src/Repository/MovieRepository.php
|
||||||
|
git commit -m "feat: resolve hint display text in computeGridData"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Install Font Awesome and update `ActorPopover` component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `assets/react/controllers/ActorPopover.jsx`
|
||||||
|
- Modify: `assets/react/controllers/GameGrid.jsx`
|
||||||
|
- Modify: `assets/react/controllers/GameRow.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install Font Awesome**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/thibaud/ltbxd-actorle && npm install @fortawesome/fontawesome-free
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Import Font Awesome CSS in the app entrypoint**
|
||||||
|
|
||||||
|
Check the main JS entrypoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
head -5 assets/app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Add at the top of `assets/app.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `ActorPopover` to accept hint props**
|
||||||
|
|
||||||
|
Replace the full content of `assets/react/controllers/ActorPopover.jsx`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react';
|
||||||
|
|
||||||
|
const HINT_ICONS = {
|
||||||
|
film: 'fa-solid fa-film',
|
||||||
|
character: 'fa-solid fa-masks-theater',
|
||||||
|
award: 'fa-solid fa-trophy',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActorPopover({ hintType, hintText }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
|
open: isOpen,
|
||||||
|
onOpenChange: setIsOpen,
|
||||||
|
middleware: [offset(8), flip(), shift()],
|
||||||
|
placement: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
const click = useClick(context);
|
||||||
|
const dismiss = useDismiss(context);
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
||||||
|
|
||||||
|
const iconClass = HINT_ICONS[hintType] || 'fa-solid fa-circle-question';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={refs.setReference}
|
||||||
|
{...getReferenceProps()}
|
||||||
|
className="popover-trigger"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i className={iconClass}></i>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
ref={refs.setFloating}
|
||||||
|
style={floatingStyles}
|
||||||
|
{...getFloatingProps()}
|
||||||
|
className="actor-popover"
|
||||||
|
>
|
||||||
|
{hintText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `GameRow` to pass hint props**
|
||||||
|
|
||||||
|
In `assets/react/controllers/GameRow.jsx`, update the component signature (line 9):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `ActorPopover` usage (line 33):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<ActorPopover hintType={hintType} hintText={hintText} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `GameGrid` to pass hint props**
|
||||||
|
|
||||||
|
In `assets/react/controllers/GameGrid.jsx`, update the `GameRow` rendering (lines 27-33):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<GameRow
|
||||||
|
key={rowIndex}
|
||||||
|
actorName={row.actorName}
|
||||||
|
pos={row.pos}
|
||||||
|
colStart={middle - row.pos}
|
||||||
|
totalWidth={width}
|
||||||
|
hintType={row.hintType}
|
||||||
|
hintText={row.hintText}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add assets/ package.json package-lock.json
|
||||||
|
git commit -m "feat: replace ? button with hint type icons in ActorPopover"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Manual end-to-end verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build frontend assets**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/thibaud/ltbxd-actorle && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Start a new game and verify**
|
||||||
|
|
||||||
|
Open the app in a browser, start a new game. Verify:
|
||||||
|
- Each row has an icon (film, masks, or trophy) instead of "?"
|
||||||
|
- Clicking the icon opens a popover with the hint text
|
||||||
|
- Different rows can have different hint types
|
||||||
|
- Award hints show the format "Nom du prix (année)"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final commit if any adjustments needed**
|
||||||
846
docs/superpowers/plans/2026-03-30-game-persistence.md
Normal file
846
docs/superpowers/plans/2026-03-30-game-persistence.md
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
# Game Grid Persistence — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Persist game grids in the database so players can resume their current game, start new games explicitly, and abandon games in progress.
|
||||||
|
|
||||||
|
**Architecture:** Two new Doctrine entities (`Game`, `GameRow`) store the grid definition. A `GameGridGenerator` service encapsulates grid creation logic extracted from `HomepageController`. The homepage checks for an active game (via User or session) and renders either the grid or a "Start a game" button. A `GameController` handles start/abandon actions.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 8, Doctrine ORM, PostgreSQL, Twig, React (unchanged components)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create the `Game` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/Game.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the Game entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRepository::class)]
|
||||||
|
class Game
|
||||||
|
{
|
||||||
|
public const string STATUS_IN_PROGRESS = 'in_progress';
|
||||||
|
public const string STATUS_ABANDONED = 'abandoned';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $mainActor = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $status = self::STATUS_IN_PROGRESS;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $startedAt;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $endedAt = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, GameRow> */
|
||||||
|
#[ORM\OneToMany(targetEntity: GameRow::class, mappedBy: 'game', cascade: ['persist'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['rowOrder' => 'ASC'])]
|
||||||
|
private Collection $rows;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->startedAt = new \DateTimeImmutable();
|
||||||
|
$this->rows = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMainActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->mainActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMainActor(Actor $mainActor): static
|
||||||
|
{
|
||||||
|
$this->mainActor = $mainActor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndedAt(?\DateTimeImmutable $endedAt): static
|
||||||
|
{
|
||||||
|
$this->endedAt = $endedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, GameRow> */
|
||||||
|
public function getRows(): Collection
|
||||||
|
{
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addRow(GameRow $row): static
|
||||||
|
{
|
||||||
|
if (!$this->rows->contains($row)) {
|
||||||
|
$this->rows->add($row);
|
||||||
|
$row->setGame($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abandon(): static
|
||||||
|
{
|
||||||
|
$this->status = self::STATUS_ABANDONED;
|
||||||
|
$this->endedAt = new \DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/Game.php
|
||||||
|
git commit -m "feat: add Game entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create the `GameRow` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/GameRow.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the GameRow entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRowRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRowRepository::class)]
|
||||||
|
class GameRow
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Game::class, inversedBy: 'rows')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Game $game = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $actor = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $position;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $rowOrder;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGame(): ?Game
|
||||||
|
{
|
||||||
|
return $this->game;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGame(Game $game): static
|
||||||
|
{
|
||||||
|
$this->game = $game;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActor(Actor $actor): static
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRowOrder(): int
|
||||||
|
{
|
||||||
|
return $this->rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRowOrder(int $rowOrder): static
|
||||||
|
{
|
||||||
|
$this->rowOrder = $rowOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/GameRow.php
|
||||||
|
git commit -m "feat: add GameRow entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create the repositories
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Repository/GameRepository.php`
|
||||||
|
- Create: `src/Repository/GameRowRepository.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create GameRepository with findActiveForUser method**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Game>
|
||||||
|
*/
|
||||||
|
class GameRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Game::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findActiveForUser(User $user): ?Game
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('g')
|
||||||
|
->andWhere('g.user = :user')
|
||||||
|
->andWhere('g.status = :status')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->setParameter('status', Game::STATUS_IN_PROGRESS)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create GameRowRepository (empty, standard)**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<GameRow>
|
||||||
|
*/
|
||||||
|
class GameRowRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, GameRow::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Repository/GameRepository.php src/Repository/GameRowRepository.php
|
||||||
|
git commit -m "feat: add Game and GameRow repositories"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Generate and run the database migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `migrations/Version2026033000001.php` (auto-generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db:migration
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A new migration file is created in `migrations/` with `CREATE TABLE game` and `CREATE TABLE game_row` statements plus foreign keys.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Review the generated migration**
|
||||||
|
|
||||||
|
Read the generated migration file. Verify it contains:
|
||||||
|
- `game` table with columns: `id`, `user_id` (nullable FK → user), `main_actor_id` (FK → actor), `status`, `started_at`, `ended_at` (nullable)
|
||||||
|
- `game_row` table with columns: `id`, `game_id` (FK → game), `actor_id` (FK → actor), `position`, `row_order`
|
||||||
|
- Proper foreign key constraints
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Migration executes successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add migrations/
|
||||||
|
git commit -m "feat: add migration for game and game_row tables"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Create the `GameGridGenerator` service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/GameGridGenerator.php`
|
||||||
|
|
||||||
|
This extracts the grid generation logic from `HomepageController::index` (lines 25-72) into a dedicated service that creates and persists a `Game` with its `GameRow` entities.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the service**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\ActorRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
class GameGridGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function generate(?User $user = null): Game
|
||||||
|
{
|
||||||
|
$mainActor = $this->actorRepository->findOneRandom(4);
|
||||||
|
|
||||||
|
$game = new Game();
|
||||||
|
$game->setMainActor($mainActor);
|
||||||
|
$game->setUser($user);
|
||||||
|
|
||||||
|
$usedActors = [$mainActor->getId()];
|
||||||
|
$rowOrder = 0;
|
||||||
|
|
||||||
|
foreach (str_split(strtolower($mainActor->getName())) as $char) {
|
||||||
|
if (!preg_match('/[a-z]/', $char)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tryFindActor = 0;
|
||||||
|
do {
|
||||||
|
$actor = $this->actorRepository->findOneRandom(4, $char);
|
||||||
|
++$tryFindActor;
|
||||||
|
} while (
|
||||||
|
in_array($actor->getId(), $usedActors)
|
||||||
|
|| $tryFindActor < 5
|
||||||
|
);
|
||||||
|
|
||||||
|
$usedActors[] = $actor->getId();
|
||||||
|
|
||||||
|
$row = new GameRow();
|
||||||
|
$row->setActor($actor);
|
||||||
|
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
||||||
|
$row->setRowOrder($rowOrder);
|
||||||
|
|
||||||
|
$game->addRow($row);
|
||||||
|
++$rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($game);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute display data (grid, width, middle) from a Game entity for the React component.
|
||||||
|
*
|
||||||
|
* @return array{grid: list<array{actorName: string, actorId: int, pos: int}>, width: int, middle: int}
|
||||||
|
*/
|
||||||
|
public function computeGridData(Game $game): array
|
||||||
|
{
|
||||||
|
$leftSize = 0;
|
||||||
|
$rightSize = 0;
|
||||||
|
$grid = [];
|
||||||
|
|
||||||
|
foreach ($game->getRows() as $row) {
|
||||||
|
$actor = $row->getActor();
|
||||||
|
$pos = $row->getPosition();
|
||||||
|
|
||||||
|
if ($leftSize < $pos) {
|
||||||
|
$leftSize = $pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rightSizeActor = strlen($actor->getName()) - $pos - 1;
|
||||||
|
if ($rightSize < $rightSizeActor) {
|
||||||
|
$rightSize = $rightSizeActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grid[] = [
|
||||||
|
'actorName' => $actor->getName(),
|
||||||
|
'actorId' => $actor->getId(),
|
||||||
|
'pos' => $pos,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'grid' => $grid,
|
||||||
|
'width' => $rightSize + $leftSize + 1,
|
||||||
|
'middle' => $leftSize,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/GameGridGenerator.php
|
||||||
|
git commit -m "feat: add GameGridGenerator service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create the `GameController`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Controller/GameController.php`
|
||||||
|
- Modify: `config/packages/security.yaml` (add `/game` routes as PUBLIC_ACCESS)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the controller**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use App\Service\GameGridGenerator;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class GameController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
|
||||||
|
public function start(
|
||||||
|
Request $request,
|
||||||
|
GameGridGenerator $generator,
|
||||||
|
GameRepository $gameRepository,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_start', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Check no game already in progress
|
||||||
|
if ($user) {
|
||||||
|
$existing = $gameRepository->findActiveForUser($user);
|
||||||
|
} else {
|
||||||
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
|
$existing = $gameId ? $gameRepository->find($gameId) : null;
|
||||||
|
if ($existing && $existing->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$existing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = $generator->generate($user);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->set('current_game_id', $game->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/game/{id}/abandon', name: 'app_game_abandon', methods: ['POST'])]
|
||||||
|
public function abandon(
|
||||||
|
Game $game,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_abandon', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if ($user) {
|
||||||
|
if ($game->getUser() !== $user) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$sessionGameId = $request->getSession()->get('current_game_id');
|
||||||
|
if ($game->getId() !== $sessionGameId) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$game->abandon();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateCsrfToken(string $tokenId, Request $request): void
|
||||||
|
{
|
||||||
|
$token = $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid($tokenId, $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `/game` routes to security access_control**
|
||||||
|
|
||||||
|
In `config/packages/security.yaml`, add a rule so `/game` routes are accessible to anonymous users (the controller handles auth logic itself):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
access_control:
|
||||||
|
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/game, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/, roles: ROLE_USER }
|
||||||
|
```
|
||||||
|
|
||||||
|
The new line `- { path: ^/game, roles: PUBLIC_ACCESS }` must go **before** the catch-all `^/` rule.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/GameController.php config/packages/security.yaml
|
||||||
|
git commit -m "feat: add GameController with start and abandon actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update `HomepageController` to use persisted games
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Controller/HomepageController.php`
|
||||||
|
|
||||||
|
Replace the on-the-fly grid generation with a lookup of the active game.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite HomepageController::index**
|
||||||
|
|
||||||
|
Replace the entire content of `src/Controller/HomepageController.php` with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use App\Service\GameGridGenerator;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class HomepageController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/', name: 'app_homepage')]
|
||||||
|
public function index(
|
||||||
|
Request $request,
|
||||||
|
GameRepository $gameRepository,
|
||||||
|
GameGridGenerator $gridGenerator,
|
||||||
|
): Response {
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$game = null;
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$game = $gameRepository->findActiveForUser($user);
|
||||||
|
} else {
|
||||||
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
|
if ($gameId) {
|
||||||
|
$game = $gameRepository->find($gameId);
|
||||||
|
if (!$game || $game->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
$game = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$game) {
|
||||||
|
return $this->render('homepage/index.html.twig', [
|
||||||
|
'game' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$gridData = $gridGenerator->computeGridData($game);
|
||||||
|
|
||||||
|
return $this->render('homepage/index.html.twig', [
|
||||||
|
'game' => $game,
|
||||||
|
'grid' => $gridData['grid'],
|
||||||
|
'width' => $gridData['width'],
|
||||||
|
'middle' => $gridData['middle'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/HomepageController.php
|
||||||
|
git commit -m "refactor: use persisted games in HomepageController"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update the homepage template
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `templates/homepage/index.html.twig`
|
||||||
|
- Modify: `assets/styles/app.css` (add game action bar styles)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the template for 3 states**
|
||||||
|
|
||||||
|
Replace the content of `templates/homepage/index.html.twig` with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% if game %}
|
||||||
|
<div class="game-actions">
|
||||||
|
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
|
||||||
|
<button type="submit" class="btn btn-abandon">Abandonner</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div {{ react_component('GameGrid', {
|
||||||
|
grid: grid,
|
||||||
|
width: width,
|
||||||
|
middle: middle,
|
||||||
|
}) }}></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="game-start-container">
|
||||||
|
<form method="post" action="{{ path('app_game_start') }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add CSS for game action bar and start button**
|
||||||
|
|
||||||
|
Add the following at the end of `assets/styles/app.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Game actions ── */
|
||||||
|
|
||||||
|
.game-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon {
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon:hover {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-start-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 64px - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
padding: 14px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/homepage/index.html.twig assets/styles/app.css
|
||||||
|
git commit -m "feat: update homepage template with start/abandon game UI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Manual verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the dev environment**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev:up
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test the flow as anonymous user**
|
||||||
|
|
||||||
|
1. Open `http://localhost` — should see "Commencer une partie" button
|
||||||
|
2. Click the button — should see a game grid with "Abandonner" button above
|
||||||
|
3. Refresh the page — the same grid should still be there
|
||||||
|
4. Click "Abandonner" — should redirect to homepage with the "Commencer une partie" button
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test the flow as connected user**
|
||||||
|
|
||||||
|
1. Log in
|
||||||
|
2. Should see "Commencer une partie" button
|
||||||
|
3. Start a game — grid appears with "Abandonner"
|
||||||
|
4. Refresh — same grid persisted
|
||||||
|
5. Abandon — back to "Commencer une partie"
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify database**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php bin/console doctrine:query:sql "SELECT id, user_id, main_actor_id, status, started_at, ended_at FROM game ORDER BY id DESC LIMIT 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify games have correct status (`in_progress` → `abandoned` after abandon), timestamps, and user_id (null for anonymous, set for connected).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Final commit (if any fixes needed)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: address issues found during manual verification"
|
||||||
|
```
|
||||||
881
docs/superpowers/plans/2026-04-01-awards-bdd.md
Normal file
881
docs/superpowers/plans/2026-04-01-awards-bdd.md
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
# Awards en BDD — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Persist actor awards in the database during film import and use them for hint generation instead of live Wikidata calls.
|
||||||
|
|
||||||
|
**Architecture:** Two new Doctrine entities (`AwardType`, `Award`) plus a `awardsImported` flag on `Actor`. A new `AwardImporter` service handles Wikidata fetching + pattern-based type resolution during the existing batch import flow. `GameGridGenerator` switches from Wikidata calls to DB queries for award hints.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 7, Doctrine ORM, PHPUnit 12, PostgreSQL (RANDOM() function used in existing queries)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| Action | Path | Responsibility |
|
||||||
|
|--------|------|---------------|
|
||||||
|
| Create | `src/Entity/AwardType.php` | Doctrine entity for award types |
|
||||||
|
| Create | `src/Entity/Award.php` | Doctrine entity for individual awards |
|
||||||
|
| Create | `src/Repository/AwardTypeRepository.php` | AwardType queries |
|
||||||
|
| Create | `src/Repository/AwardRepository.php` | Award queries (random by actor) |
|
||||||
|
| Create | `src/Service/AwardImporter.php` | Orchestrates Wikidata fetch + type resolution + persist |
|
||||||
|
| Create | `tests/Service/AwardImporterTest.php` | Unit tests for import logic |
|
||||||
|
| Create | `tests/Service/GameGridGeneratorTest.php` | Unit tests for updated hint generation |
|
||||||
|
| Modify | `src/Entity/Actor.php` | Add `awardsImported` bool field + `awards` collection |
|
||||||
|
| Modify | `src/MessageHandler/ImportFilmsBatchMessageHandler.php` | Call `AwardImporter` after actor sync |
|
||||||
|
| Modify | `src/Service/GameGridGenerator.php` | Replace Wikidata call with DB query |
|
||||||
|
| Create | `migrations/VersionXXX.php` | Generated by doctrine:migrations:diff |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create `AwardType` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/AwardType.php`
|
||||||
|
- Create: `src/Repository/AwardTypeRepository.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: AwardTypeRepository::class)]
|
||||||
|
class AwardType
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $pattern;
|
||||||
|
|
||||||
|
/** @var Collection<int, Award> */
|
||||||
|
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'awardType')]
|
||||||
|
private Collection $awards;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->awards = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPattern(): string
|
||||||
|
{
|
||||||
|
return $this->pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPattern(string $pattern): static
|
||||||
|
{
|
||||||
|
$this->pattern = $pattern;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Award> */
|
||||||
|
public function getAwards(): Collection
|
||||||
|
{
|
||||||
|
return $this->awards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the repository**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/** @extends ServiceEntityRepository<AwardType> */
|
||||||
|
class AwardTypeRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, AwardType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<AwardType> */
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
return parent::findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/AwardType.php src/Repository/AwardTypeRepository.php
|
||||||
|
git commit -m "feat: add AwardType entity and repository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create `Award` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/Award.php`
|
||||||
|
- Create: `src/Repository/AwardRepository.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\AwardRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: AwardRepository::class)]
|
||||||
|
class Award
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: AwardType::class, inversedBy: 'awards')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private AwardType $awardType;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class, inversedBy: 'awards')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private Actor $actor;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $year = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAwardType(): AwardType
|
||||||
|
{
|
||||||
|
return $this->awardType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAwardType(AwardType $awardType): static
|
||||||
|
{
|
||||||
|
$this->awardType = $awardType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): Actor
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActor(Actor $actor): static
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getYear(): ?int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(?int $year): static
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the repository with `findOneRandomByActor`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Award;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/** @extends ServiceEntityRepository<Award> */
|
||||||
|
class AwardRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Award::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneRandomByActor(int $actorId): ?Award
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('a')
|
||||||
|
->andWhere('a.actor = :actorId')
|
||||||
|
->setParameter('actorId', $actorId)
|
||||||
|
->orderBy('RANDOM()')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/Award.php src/Repository/AwardRepository.php
|
||||||
|
git commit -m "feat: add Award entity and repository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add `awardsImported` flag and `awards` collection to `Actor`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/Actor.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `awardsImported` column and `awards` OneToMany relation**
|
||||||
|
|
||||||
|
Add these properties after the existing `$tmdbId` property:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
private bool $awardsImported = false;
|
||||||
|
|
||||||
|
/** @var Collection<int, Award> */
|
||||||
|
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'actor')]
|
||||||
|
private Collection $awards;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `use App\Entity\Award;` import if not already present (it's in the same namespace, so not needed).
|
||||||
|
|
||||||
|
In the constructor, add:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->awards = new ArrayCollection();
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these methods at the end of the class:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function isAwardsImported(): bool
|
||||||
|
{
|
||||||
|
return $this->awardsImported;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAwardsImported(bool $awardsImported): static
|
||||||
|
{
|
||||||
|
$this->awardsImported = $awardsImported;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Award> */
|
||||||
|
public function getAwards(): Collection
|
||||||
|
{
|
||||||
|
return $this->awards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/Actor.php
|
||||||
|
git commit -m "feat: add awardsImported flag and awards relation to Actor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Generate and run the migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `migrations/VersionXXX.php` (auto-generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console doctrine:migrations:diff
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a new migration file is created in `migrations/` with CREATE TABLE for `award_type` and `award`, and ALTER TABLE for `actor` adding `awards_imported`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Review the generated migration**
|
||||||
|
|
||||||
|
Open the generated file and verify it contains:
|
||||||
|
- `CREATE TABLE award_type (id SERIAL, name VARCHAR(255), pattern VARCHAR(255), PRIMARY KEY(id))`
|
||||||
|
- `CREATE TABLE award (id SERIAL, award_type_id INT NOT NULL, actor_id INT NOT NULL, name VARCHAR(255), year INT DEFAULT NULL, PRIMARY KEY(id))` with foreign keys
|
||||||
|
- `ALTER TABLE actor ADD awards_imported BOOLEAN NOT NULL DEFAULT false`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: migration executes successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add migrations/
|
||||||
|
git commit -m "feat: add migration for award_type, award tables and actor.awards_imported"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Create `AwardImporter` service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/AwardImporter.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/Service/AwardImporterTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Award;
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use App\Gateway\WikidataGateway;
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use App\Service\AwardImporter;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AwardImporterTest extends TestCase
|
||||||
|
{
|
||||||
|
private AwardImporter $importer;
|
||||||
|
private WikidataGateway&\PHPUnit\Framework\MockObject\MockObject $wikidataGateway;
|
||||||
|
private AwardTypeRepository&\PHPUnit\Framework\MockObject\MockObject $awardTypeRepository;
|
||||||
|
private EntityManagerInterface&\PHPUnit\Framework\MockObject\MockObject $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->wikidataGateway = $this->createMock(WikidataGateway::class);
|
||||||
|
$this->awardTypeRepository = $this->createMock(AwardTypeRepository::class);
|
||||||
|
$this->em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->importer = new AwardImporter(
|
||||||
|
$this->wikidataGateway,
|
||||||
|
$this->awardTypeRepository,
|
||||||
|
$this->em,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSkipsActorWithAwardsAlreadyImported(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag(awardsImported: true);
|
||||||
|
|
||||||
|
$this->wikidataGateway->expects($this->never())->method('getAwards');
|
||||||
|
|
||||||
|
$this->importer->importForActor($actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testImportsAwardsAndSetsFlag(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwards')->willReturn([
|
||||||
|
['name' => 'Academy Award for Best Actor', 'year' => 2020],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existingType = new AwardType();
|
||||||
|
$existingType->setName('Oscar')->setPattern('Academy Award');
|
||||||
|
|
||||||
|
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
|
||||||
|
|
||||||
|
$persisted = [];
|
||||||
|
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||||
|
$persisted[] = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->importer->importForActor($actor);
|
||||||
|
|
||||||
|
$this->assertTrue($actor->isAwardsImported());
|
||||||
|
$this->assertCount(1, $persisted);
|
||||||
|
$this->assertInstanceOf(Award::class, $persisted[0]);
|
||||||
|
$this->assertSame('Academy Award for Best Actor', $persisted[0]->getName());
|
||||||
|
$this->assertSame(2020, $persisted[0]->getYear());
|
||||||
|
$this->assertSame($existingType, $persisted[0]->getAwardType());
|
||||||
|
$this->assertSame($actor, $persisted[0]->getActor());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreatesNewAwardTypeWhenNoPatternMatches(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwards')->willReturn([
|
||||||
|
['name' => 'Screen Actors Guild Award for Outstanding Performance', 'year' => 2019],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||||
|
|
||||||
|
$persisted = [];
|
||||||
|
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||||
|
$persisted[] = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->importer->importForActor($actor);
|
||||||
|
|
||||||
|
$this->assertTrue($actor->isAwardsImported());
|
||||||
|
// Should persist both a new AwardType and the Award
|
||||||
|
$this->assertCount(2, $persisted);
|
||||||
|
|
||||||
|
$newType = $persisted[0];
|
||||||
|
$this->assertInstanceOf(AwardType::class, $newType);
|
||||||
|
$this->assertSame('Screen Actors Guild Award', $newType->getName());
|
||||||
|
$this->assertSame('Screen Actors Guild Award', $newType->getPattern());
|
||||||
|
|
||||||
|
$award = $persisted[1];
|
||||||
|
$this->assertInstanceOf(Award::class, $award);
|
||||||
|
$this->assertSame($newType, $award->getAwardType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotSetFlagOnWikidataError(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwards')
|
||||||
|
->willThrowException(new \RuntimeException('Wikidata timeout'));
|
||||||
|
|
||||||
|
$this->importer->importForActor($actor);
|
||||||
|
|
||||||
|
$this->assertFalse($actor->isAwardsImported());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHandlesActorWithNoAwards(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag(awardsImported: false);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwards')->willReturn([]);
|
||||||
|
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||||
|
|
||||||
|
$this->em->expects($this->never())->method('persist');
|
||||||
|
|
||||||
|
$this->importer->importForActor($actor);
|
||||||
|
|
||||||
|
$this->assertTrue($actor->isAwardsImported());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createActorWithFlag(bool $awardsImported): Actor
|
||||||
|
{
|
||||||
|
$actor = new Actor();
|
||||||
|
$actor->setName('Test Actor');
|
||||||
|
$actor->setAwardsImported($awardsImported);
|
||||||
|
|
||||||
|
return $actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/phpunit tests/Service/AwardImporterTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `AwardImporter` class not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the implementation**
|
||||||
|
|
||||||
|
Create `src/Service/AwardImporter.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Award;
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use App\Gateway\WikidataGateway;
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
readonly class AwardImporter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WikidataGateway $wikidataGateway,
|
||||||
|
private AwardTypeRepository $awardTypeRepository,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private ?LoggerInterface $logger = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function importForActor(Actor $actor): void
|
||||||
|
{
|
||||||
|
if ($actor->isAwardsImported()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$wikidataAwards = $this->wikidataGateway->getAwards($actor);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger?->warning('Failed to fetch awards from Wikidata', [
|
||||||
|
'actor' => $actor->getName(),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$knownTypes = $this->awardTypeRepository->findAll();
|
||||||
|
|
||||||
|
foreach ($wikidataAwards as $wikidataAward) {
|
||||||
|
$awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes);
|
||||||
|
|
||||||
|
$award = new Award();
|
||||||
|
$award->setName($wikidataAward['name']);
|
||||||
|
$award->setYear($wikidataAward['year']);
|
||||||
|
$award->setActor($actor);
|
||||||
|
$award->setAwardType($awardType);
|
||||||
|
|
||||||
|
$this->em->persist($award);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor->setAwardsImported(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AwardType> $knownTypes
|
||||||
|
*/
|
||||||
|
private function resolveAwardType(string $awardName, array &$knownTypes): AwardType
|
||||||
|
{
|
||||||
|
foreach ($knownTypes as $type) {
|
||||||
|
if (str_contains($awardName, $type->getPattern())) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newType = new AwardType();
|
||||||
|
$prefix = $this->extractPrefix($awardName);
|
||||||
|
$newType->setName($prefix);
|
||||||
|
$newType->setPattern($prefix);
|
||||||
|
|
||||||
|
$this->em->persist($newType);
|
||||||
|
$knownTypes[] = $newType;
|
||||||
|
|
||||||
|
return $newType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractPrefix(string $awardName): string
|
||||||
|
{
|
||||||
|
// Extract text before " for " or " pour " (common patterns in award names)
|
||||||
|
if (preg_match('/^(.+?)\s+(?:for|pour)\s+/i', $awardName, $matches)) {
|
||||||
|
return trim($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $awardName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/phpunit tests/Service/AwardImporterTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 5 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/AwardImporter.php tests/Service/AwardImporterTest.php
|
||||||
|
git commit -m "feat: add AwardImporter service with tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Integrate `AwardImporter` into the import batch handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/MessageHandler/ImportFilmsBatchMessageHandler.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `AwardImporter` dependency**
|
||||||
|
|
||||||
|
In the constructor, add:
|
||||||
|
|
||||||
|
```php
|
||||||
|
private AwardImporter $awardImporter,
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import at the top:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Service\AwardImporter;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Call `AwardImporter` after actor sync**
|
||||||
|
|
||||||
|
In the `__invoke` method, after `$this->actorSyncer->syncActorsForMovie($movie);` (line 63) and **before** the existing `$this->em->flush()` (line 77), add:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->actorSyncer->syncActorsForMovie($movie);
|
||||||
|
|
||||||
|
// Import awards for actors of this movie
|
||||||
|
foreach ($movie->getActors() as $role) {
|
||||||
|
$this->awardImporter->importForActor($role->getActor());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Movie::getActors()` returns `Collection<MovieRole>`. The `ActorSyncer` persists actors/roles in memory before flush, so the collection is hydrated at this point. The existing `$this->em->flush()` on line 77 will persist both the roles and the new awards in a single flush. The `$this->em->clear()` on line 87 happens after, so all entities are still available.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/MessageHandler/ImportFilmsBatchMessageHandler.php
|
||||||
|
git commit -m "feat: import actor awards during film batch import"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update `GameGridGenerator` to use DB for award hints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/GameGridGenerator.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/Service/GameGridGeneratorTest.php` — test only the hint resolution, not the full game generation (which requires DB):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Award;
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use App\Repository\ActorRepository;
|
||||||
|
use App\Repository\AwardRepository;
|
||||||
|
use App\Repository\MovieRepository;
|
||||||
|
use App\Repository\MovieRoleRepository;
|
||||||
|
use App\Service\GameGridGenerator;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class GameGridGeneratorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testResolveHintTextForAward(): void
|
||||||
|
{
|
||||||
|
$awardType = new AwardType();
|
||||||
|
$awardType->setName('Oscar')->setPattern('Academy Award');
|
||||||
|
|
||||||
|
$actor = new Actor();
|
||||||
|
$actor->setName('Test Actor');
|
||||||
|
|
||||||
|
$award = new Award();
|
||||||
|
$award->setName('Academy Award for Best Actor');
|
||||||
|
$award->setYear(2020);
|
||||||
|
$award->setActor($actor);
|
||||||
|
$award->setAwardType($awardType);
|
||||||
|
|
||||||
|
$awardRepository = $this->createMock(AwardRepository::class);
|
||||||
|
$awardRepository->method('find')->with(42)->willReturn($award);
|
||||||
|
|
||||||
|
$generator = new GameGridGenerator(
|
||||||
|
$this->createMock(ActorRepository::class),
|
||||||
|
$this->createMock(MovieRoleRepository::class),
|
||||||
|
$this->createMock(MovieRepository::class),
|
||||||
|
$awardRepository,
|
||||||
|
$this->createMock(EntityManagerInterface::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = new GameRow();
|
||||||
|
$row->setHintType('award');
|
||||||
|
$row->setHintData('42');
|
||||||
|
|
||||||
|
// Use reflection to test the private resolveHintText method
|
||||||
|
$method = new \ReflectionMethod($generator, 'resolveHintText');
|
||||||
|
$result = $method->invoke($generator, $row);
|
||||||
|
|
||||||
|
$this->assertSame('Academy Award for Best Actor (2020)', $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/phpunit tests/Service/GameGridGeneratorTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — constructor signature mismatch (no `AwardRepository` param yet).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `GameGridGenerator`**
|
||||||
|
|
||||||
|
Replace `WikidataGateway` with `AwardRepository` in the constructor. Full changes to `src/Service/GameGridGenerator.php`:
|
||||||
|
|
||||||
|
**Replace the imports:**
|
||||||
|
- Remove: `use App\Gateway\WikidataGateway;`
|
||||||
|
- Add: `use App\Repository\AwardRepository;`
|
||||||
|
|
||||||
|
**Replace the constructor:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly MovieRoleRepository $movieRoleRepository,
|
||||||
|
private readonly MovieRepository $movieRepository,
|
||||||
|
private readonly AwardRepository $awardRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace the `case 'award':` block in `resolveHint()`** (lines 175-185):
|
||||||
|
|
||||||
|
```php
|
||||||
|
case 'award':
|
||||||
|
$award = $this->awardRepository->findOneRandomByActor($rowActor->getId());
|
||||||
|
if ($award === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['type' => 'award', 'data' => (string) $award->getId()];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace the `'award'` case in `resolveHintText()`** (line 203):
|
||||||
|
|
||||||
|
```php
|
||||||
|
'award' => $this->resolveAwardHintText((int) $data),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add this private method:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function resolveAwardHintText(int $awardId): ?string
|
||||||
|
{
|
||||||
|
$award = $this->awardRepository->find($awardId);
|
||||||
|
if ($award === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $award->getName();
|
||||||
|
if ($award->getYear() !== null) {
|
||||||
|
$text .= ' (' . $award->getYear() . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/phpunit tests/Service/GameGridGeneratorTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/GameGridGenerator.php tests/Service/GameGridGeneratorTest.php
|
||||||
|
git commit -m "feat: use DB awards instead of live Wikidata calls for hint generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Manual verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify the schema is in sync**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console doctrine:schema:validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: schema is in sync with mapping files.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the app boots**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console cache:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run all tests one final time**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final commit if any fixes were needed**
|
||||||
|
|
||||||
|
Only if adjustments were made during verification.
|
||||||
1176
docs/superpowers/plans/2026-04-01-game-config-panel.md
Normal file
1176
docs/superpowers/plans/2026-04-01-game-config-panel.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
|||||||
|
# User Film Import, Navbar & Notifications
|
||||||
|
|
||||||
|
**Date:** 2026-03-29
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a navbar for authenticated users with a user dropdown (import films, logout) and a notifications dropdown (with unread count badge and page title update). Users can import their Letterboxd CSV to sync films and actors via async processing, with files stored on a remote SeaweedFS instance.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New Entities
|
||||||
|
|
||||||
|
**`UserMovie`** — join table User <-> Movie
|
||||||
|
- `id` (int, PK)
|
||||||
|
- `user` (ManyToOne -> User)
|
||||||
|
- `movie` (ManyToOne -> Movie)
|
||||||
|
- Unique constraint on `(user, movie)`
|
||||||
|
|
||||||
|
**`Import`** — tracks a CSV import job
|
||||||
|
- `id` (int, PK)
|
||||||
|
- `user` (ManyToOne -> User)
|
||||||
|
- `filePath` (string) — path on SeaweedFS
|
||||||
|
- `status` (string, enum: `pending`, `processing`, `completed`, `failed`)
|
||||||
|
- `totalBatches` (int, default 0)
|
||||||
|
- `processedBatches` (int, default 0)
|
||||||
|
- `totalFilms` (int, default 0)
|
||||||
|
- `failedFilms` (int, default 0)
|
||||||
|
- `createdAt` (datetime)
|
||||||
|
- `completedAt` (datetime, nullable)
|
||||||
|
|
||||||
|
**`Notification`** — user notifications
|
||||||
|
- `id` (int, PK)
|
||||||
|
- `user` (ManyToOne -> User)
|
||||||
|
- `message` (string)
|
||||||
|
- `read` (bool, default false)
|
||||||
|
- `createdAt` (datetime)
|
||||||
|
|
||||||
|
### Modified Entities
|
||||||
|
|
||||||
|
**`User`** — add OneToMany relations to `UserMovie`, `Import`, `Notification`.
|
||||||
|
|
||||||
|
## File Storage (SeaweedFS)
|
||||||
|
|
||||||
|
- **Library:** `league/flysystem-aws-s3-v3` with Flysystem S3 adapter
|
||||||
|
- **Endpoint:** `s3.lclr.dev`
|
||||||
|
- **Bucket:** `ltbxd-actorle`
|
||||||
|
- **Credentials:** Symfony Secrets (`S3_ACCESS_KEY`, `S3_SECRET_KEY`)
|
||||||
|
- **File path pattern:** `imports/{userId}/{importId}.csv`
|
||||||
|
- No local/Docker SeaweedFS — always the remote instance, including in dev.
|
||||||
|
|
||||||
|
## Async Processing (Messenger)
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
**`ProcessImportMessage(importId)`**
|
||||||
|
- Dispatched by the upload controller.
|
||||||
|
- Single entry point for import processing.
|
||||||
|
|
||||||
|
**`ImportFilmsBatchMessage(importId, offset, limit)`**
|
||||||
|
- Dispatched by `ProcessImportMessageHandler`.
|
||||||
|
- One per batch of 50 films.
|
||||||
|
|
||||||
|
### Handler: `ProcessImportMessageHandler`
|
||||||
|
|
||||||
|
1. Fetch `Import` entity
|
||||||
|
2. Download CSV from SeaweedFS via Flysystem
|
||||||
|
3. Parse the file: save to a temp file, then use `LtbxdGateway->parseFile()` (which expects a local path), then delete the temp file
|
||||||
|
4. Calculate `totalFilms`, `totalBatches` (batches of 50), update the Import
|
||||||
|
5. Dispatch N `ImportFilmsBatchMessage(importId, offset, limit)` messages
|
||||||
|
6. Set Import status to `processing`
|
||||||
|
|
||||||
|
### Handler: `ImportFilmsBatchMessageHandler`
|
||||||
|
|
||||||
|
1. Fetch Import, download CSV from SeaweedFS, read slice [offset, offset+limit]
|
||||||
|
2. For each film in the slice:
|
||||||
|
- Look up by `ltbxdRef` in DB; if missing, call `TMDBGateway->searchMovie()` and create Movie
|
||||||
|
- Fetch actors via TMDB, create missing Actor/MovieRole entries
|
||||||
|
- Create `UserMovie` link if it doesn't exist
|
||||||
|
3. Atomically increment `processedBatches` (`UPDATE ... SET processed_batches = processed_batches + 1`) to avoid race conditions with multiple workers
|
||||||
|
4. If `processedBatches == totalBatches`: set Import to `completed`, set `completedAt`, create Notification ("Import terminé : X/Y films importés")
|
||||||
|
5. On per-film error: log and continue, increment `failedFilms`
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- `ProcessImportMessageHandler` failure (SeaweedFS down, invalid CSV): set Import to `failed`, create error Notification.
|
||||||
|
- `ImportFilmsBatchMessageHandler` per-film failure: log, skip film, increment `failedFilms`, continue.
|
||||||
|
- Messenger retry: default config (3 retries with backoff), then failure transport.
|
||||||
|
|
||||||
|
## Extracted Services
|
||||||
|
|
||||||
|
The logic currently embedded in `SyncFilmsCommand` and `SyncActorsCommand` is extracted into reusable services:
|
||||||
|
|
||||||
|
- **`FilmImporter`** — given a parsed CSV row, finds or creates a Movie entity via TMDB lookup.
|
||||||
|
- **`ActorSyncer`** — given a Movie, fetches cast from TMDB and creates missing Actor/MovieRole entries.
|
||||||
|
|
||||||
|
The existing commands are refactored to use these services.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `POST /api/imports` (authenticated)
|
||||||
|
- **Input:** CSV file as multipart form data
|
||||||
|
- **Validation:** `.csv` extension, max 5 MB
|
||||||
|
- **Action:** Upload to SeaweedFS, create Import entity (status `pending`), dispatch `ProcessImportMessage`
|
||||||
|
- **Response:** `201 { id, status: "pending" }`
|
||||||
|
|
||||||
|
### `GET /api/notifications` (authenticated)
|
||||||
|
- **Response:** `200 { unreadCount: N, notifications: [{ id, message, read, createdAt }] }`
|
||||||
|
- Sorted by `createdAt` desc, limited to 20 most recent
|
||||||
|
|
||||||
|
### `POST /api/notifications/read` (authenticated)
|
||||||
|
- **Action:** Mark all notifications for the authenticated user as read
|
||||||
|
- **Response:** `204`
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Navbar (Twig + Stimulus)
|
||||||
|
|
||||||
|
- Added in the main layout (`base.html.twig` or partial), visible only when authenticated.
|
||||||
|
- Right side: notification icon (bell) + user icon.
|
||||||
|
|
||||||
|
### User Dropdown (`dropdown_controller` Stimulus)
|
||||||
|
|
||||||
|
- Click user icon -> toggle dropdown menu
|
||||||
|
- Entries: "Importer ses films", "Se deconnecter"
|
||||||
|
- Click outside -> close
|
||||||
|
|
||||||
|
### Notifications Dropdown (`notifications_controller` Stimulus)
|
||||||
|
|
||||||
|
- Click bell icon -> dropdown listing recent notifications
|
||||||
|
- Polling every 30s on `GET /api/notifications` returning notifications + unread count
|
||||||
|
- On dropdown open: call `POST /api/notifications/read` to mark as read
|
||||||
|
- Badge (red, unread count) updates on each poll
|
||||||
|
- `document.title` updates: `(N) Actorle` if unread > 0, `Actorle` otherwise
|
||||||
|
|
||||||
|
### Import Modal (`import_modal_controller` Stimulus)
|
||||||
|
|
||||||
|
- Click "Importer ses films" -> show modal (HTML in DOM, toggle `hidden`)
|
||||||
|
- File input (accept `.csv`)
|
||||||
|
- "Importer" button -> `POST /api/imports` via fetch (multipart)
|
||||||
|
- On success: "Import lance !" message, close modal
|
||||||
|
- Client-side validation: `.csv` extension only
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Modifying game logic based on user's imported films (future: game config page)
|
||||||
|
- Mercure/WebSocket for real-time notifications (polling is sufficient)
|
||||||
|
- Docker SeaweedFS for local dev
|
||||||
71
docs/superpowers/specs/2026-03-30-game-hints-design.md
Normal file
71
docs/superpowers/specs/2026-03-30-game-hints-design.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Game Hints System — Design Spec
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Each row of the game grid provides a hint about the main actor to guess. Hints are pre-generated when the grid is created and stored on `GameRow`. Each hint has a type (film, character, award) represented by a distinct icon. Clicking the icon opens a popover showing the hint text.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Two new columns on `game_row`:
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `hint_type` | `VARCHAR(20)`, NOT NULL | One of: `film`, `character`, `award` |
|
||||||
|
| `hint_data` | `VARCHAR(255)`, NOT NULL | film → `movie.id`, character → `movie_role.id`, award → text "Nom du prix (année)" |
|
||||||
|
|
||||||
|
No foreign key constraints on `hint_data` — the column stores either an id (resolved at read time) or raw text depending on `hint_type`.
|
||||||
|
|
||||||
|
## Hint Generation
|
||||||
|
|
||||||
|
Happens in `GameGridGenerator::generate()`, for each `GameRow`:
|
||||||
|
|
||||||
|
1. Pick a random type from `[film, character, award]`
|
||||||
|
2. Resolve based on type:
|
||||||
|
- **film**: pick a random `MovieRole` of the main actor → store `movie.id` in `hint_data`
|
||||||
|
- **character**: pick a random `MovieRole` of the main actor → store `movieRole.id` in `hint_data`
|
||||||
|
- **award**: call `WikidataAwardGateway` to fetch an award → store `"Nom du prix (année)"` in `hint_data`
|
||||||
|
3. If the chosen type yields no result (e.g., no awards found), fallback to another random type
|
||||||
|
4. Avoid duplicate hints across rows (don't show the same film/character/award twice)
|
||||||
|
|
||||||
|
## Wikidata Award Gateway
|
||||||
|
|
||||||
|
New service: `WikidataAwardGateway`
|
||||||
|
|
||||||
|
- Input: actor (name or `tmdb_id`)
|
||||||
|
- Output: list of awards, each with name and year
|
||||||
|
- Uses Wikidata SPARQL API to query awards associated with the person
|
||||||
|
- Storage format in `hint_data`: `"Oscar du meilleur second rôle (2014)"`
|
||||||
|
- Results can be cached to avoid repeated Wikidata queries
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Icon Button
|
||||||
|
|
||||||
|
The current "?" button in `ActorPopover` is replaced by an icon representing the hint type:
|
||||||
|
|
||||||
|
| hint_type | Icon | Font Awesome class |
|
||||||
|
|-----------|------|--------------------|
|
||||||
|
| `film` | Film/clap | `fa-film` |
|
||||||
|
| `character` | Theater masks | `fa-masks-theater` |
|
||||||
|
| `award` | Trophy | `fa-trophy` |
|
||||||
|
|
||||||
|
### Popover Content
|
||||||
|
|
||||||
|
On click, the popover displays only the hint text:
|
||||||
|
- **film**: movie title (resolved from `movie.id`)
|
||||||
|
- **character**: character name (resolved from `movie_role.id`)
|
||||||
|
- **award**: the raw text from `hint_data`
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. Backend resolves `hint_data` to display text in `GameGridGenerator::computeGridData()`
|
||||||
|
2. Hint type + resolved text are passed to the Twig template as part of the grid data
|
||||||
|
3. Twig passes them as props to the React `GameGrid` component
|
||||||
|
4. `ActorPopover` receives `hintType` and `hintText` props instead of `actorName`
|
||||||
|
|
||||||
|
## Future Extensibility
|
||||||
|
|
||||||
|
New hint types can be added by:
|
||||||
|
1. Adding a new value for `hint_type`
|
||||||
|
2. Adding resolution logic in `GameGridGenerator`
|
||||||
|
3. Adding a new icon mapping in the frontend
|
||||||
108
docs/superpowers/specs/2026-03-30-game-persistence-design.md
Normal file
108
docs/superpowers/specs/2026-03-30-game-persistence-design.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Game Grid Persistence
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Game grids are currently generated on the fly at every page load in `HomepageController`. There is no persistence — refreshing the page gives a new grid. Players cannot resume a game.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Persist game grids so players can resume their current game. Display a "Start a game" button when no game is in progress. Allow players to abandon a game.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Grid generation and persistence only
|
||||||
|
- No win/loss logic (will be added later)
|
||||||
|
- No saving of player input (filled letters)
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Entity: `Game`
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------------|-------------------------|------------------------------------------------|
|
||||||
|
| `id` | int (auto) | Primary key |
|
||||||
|
| `user` | ManyToOne → User, nullable | Null for anonymous players |
|
||||||
|
| `mainActor` | ManyToOne → Actor | The actor to guess |
|
||||||
|
| `status` | string | `in_progress` or `abandoned` (extensible to `won` later) |
|
||||||
|
| `startedAt` | DateTimeImmutable | When the game started |
|
||||||
|
| `endedAt` | DateTimeImmutable, nullable | When the game ended (abandon or win) |
|
||||||
|
|
||||||
|
### Entity: `GameRow`
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|------------|--------------------|------------------------------------------------|
|
||||||
|
| `id` | int (auto) | Primary key |
|
||||||
|
| `game` | ManyToOne → Game | Parent game |
|
||||||
|
| `actor` | ManyToOne → Actor | The actor for this row |
|
||||||
|
| `position` | int | Index of the target letter in the actor's name |
|
||||||
|
| `rowOrder` | int | Order of this row in the grid |
|
||||||
|
|
||||||
|
`GameRow` is a separate entity (not JSON) to allow attaching hints/clues per row in a future iteration.
|
||||||
|
|
||||||
|
## Service: `GameGridGenerator`
|
||||||
|
|
||||||
|
Extracted from the current `HomepageController::index` logic:
|
||||||
|
|
||||||
|
1. Select a random main actor via `ActorRepository::findOneRandom()`
|
||||||
|
2. For each letter (a-z) in the main actor's name, find a random actor containing that letter
|
||||||
|
3. Create a `Game` entity with `status = in_progress` and `startedAt = now`
|
||||||
|
4. Create `GameRow` entities with the actor, letter position, and row order
|
||||||
|
5. Persist and flush
|
||||||
|
6. Return the `Game` entity
|
||||||
|
|
||||||
|
Also provides a method to compute grid display data (width, middle) from a `Game` and its rows, for passing to the React component.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### `GET /` — Homepage (modified)
|
||||||
|
|
||||||
|
Current behavior replaced:
|
||||||
|
|
||||||
|
1. If user is connected → query `GameRepository` for a `Game` with `status = in_progress` for this user
|
||||||
|
2. If anonymous → get `current_game_id` from the Symfony session, then look up the `Game` in DB
|
||||||
|
3. If a game is found and still `in_progress` → render the grid with an "Abandon" button above it
|
||||||
|
4. If no game is found → render a "Start a game" button
|
||||||
|
|
||||||
|
### `POST /game/start` — Start a new game (new)
|
||||||
|
|
||||||
|
1. Verify no game is already in progress for this player (connected or session)
|
||||||
|
2. Call `GameGridGenerator` to create the game
|
||||||
|
3. If user is connected → the game is linked to the user
|
||||||
|
4. If anonymous → store `$game->getId()` in session key `current_game_id`
|
||||||
|
5. Redirect to `/`
|
||||||
|
|
||||||
|
### `POST /game/{id}/abandon` — Abandon a game (new)
|
||||||
|
|
||||||
|
1. Verify the game belongs to the current player (user match or session match)
|
||||||
|
2. Set `status = abandoned` and `endedAt = now`
|
||||||
|
3. If anonymous → remove `current_game_id` from session
|
||||||
|
4. Redirect to `/`
|
||||||
|
|
||||||
|
Both POST routes use CSRF protection.
|
||||||
|
|
||||||
|
## Anonymous Session Handling
|
||||||
|
|
||||||
|
- Session key: `current_game_id` (stores the `Game` ID)
|
||||||
|
- Set on game creation, removed on abandon
|
||||||
|
- On homepage load: if the stored game ID no longer exists or is not `in_progress`, clean up the session key
|
||||||
|
- No migration of anonymous games on login — the anonymous game is lost when the user logs in. Acceptable since we don't save letter input.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Template changes (`homepage/index.html.twig`)
|
||||||
|
|
||||||
|
Three states:
|
||||||
|
|
||||||
|
1. **No game in progress**: centered "Start a game" button (Twig form, POST to `/game/start`)
|
||||||
|
2. **Game in progress**: action bar above the grid with an "Abandon" button (Twig form, POST to `/game/{id}/abandon` with CSRF token), then the React `GameGrid` component below
|
||||||
|
3. **After abandon**: redirect to `/`, which shows state 1
|
||||||
|
|
||||||
|
### React components
|
||||||
|
|
||||||
|
No changes to `GameGrid`, `GameRow`, `LetterInput`, or `ActorPopover`. They receive the same props (`grid`, `width`, `middle`) — the data source changes from on-the-fly generation to database lookup.
|
||||||
|
|
||||||
|
## Future Extensibility
|
||||||
|
|
||||||
|
- **Win logic**: add `won` to the status enum, set `endedAt` on win
|
||||||
|
- **Hints/clues per row**: add a relation on `GameRow` (e.g., `GameRowHint` entity)
|
||||||
|
- **Game history/stats**: query `Game` entities by user with status filters
|
||||||
93
docs/superpowers/specs/2026-04-01-awards-bdd-design.md
Normal file
93
docs/superpowers/specs/2026-04-01-awards-bdd-design.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Awards en BDD — Design Spec
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Actuellement, les récompenses des acteurs sont récupérées à la volée depuis Wikidata (SPARQL) lors de la génération d'une partie. Elles ne sont pas persistées en base de données, ce qui rend le système fragile (dépendance réseau à chaque partie) et empêche tout filtrage par type de récompense.
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
1. Stocker les awards en BDD dès l'import des films
|
||||||
|
2. Introduire une notion de type de récompense (Oscar, Golden Globe, BAFTA...)
|
||||||
|
3. Adapter la génération des indices pour piocher en BDD au lieu d'appeler Wikidata
|
||||||
|
4. Préparer le terrain pour un futur filtrage par type (choix du joueur avant la partie) — hors scope de cette itération
|
||||||
|
|
||||||
|
## Modèle de données
|
||||||
|
|
||||||
|
### Nouvelle entité : `AwardType`
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|-----------|------------|--------------------------------------------------------------------|
|
||||||
|
| `id` | int (PK) | Auto-increment |
|
||||||
|
| `name` | string | Nom affiché, ex: "Oscar", "Golden Globe", "BAFTA", "César" |
|
||||||
|
| `pattern` | string | Préfixe/mot-clé pour le matching sur les noms Wikidata, ex: "Academy Award" |
|
||||||
|
|
||||||
|
### Nouvelle entité : `Award`
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|-------------|-------------------|--------------------------------------------------------------|
|
||||||
|
| `id` | int (PK) | Auto-increment |
|
||||||
|
| `awardType` | ManyToOne → AwardType | Type de la récompense |
|
||||||
|
| `actor` | ManyToOne → Actor | Acteur récompensé |
|
||||||
|
| `name` | string | Nom complet Wikidata, ex: "Academy Award for Best Actor" |
|
||||||
|
| `year` | int (nullable) | Année de la récompense |
|
||||||
|
|
||||||
|
### Modification entité `Actor`
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|------------------|------|--------------------------------------------------|
|
||||||
|
| `awardsImported` | bool | `false` par défaut. Passe à `true` après import. |
|
||||||
|
|
||||||
|
### Relations
|
||||||
|
|
||||||
|
- `Actor` OneToMany → `Award`
|
||||||
|
- `AwardType` OneToMany → `Award`
|
||||||
|
|
||||||
|
## Flux d'import des awards
|
||||||
|
|
||||||
|
L'import se greffe sur le batch existant (`ImportFilmsBatchMessageHandler`), après `ActorSyncer::syncActorsForMovie()`.
|
||||||
|
|
||||||
|
### Étapes pour chaque acteur du film importé
|
||||||
|
|
||||||
|
1. Vérifier `actor.awardsImported`
|
||||||
|
2. Si `true` → skip
|
||||||
|
3. Si `false` → appeler `WikidataGateway::getAwards(actor)`
|
||||||
|
4. Pour chaque award retourné :
|
||||||
|
- Parcourir les `AwardType` existants et matcher le nom de l'award contre leur `pattern`
|
||||||
|
- Si un `AwardType` matche → l'utiliser
|
||||||
|
- Si aucun ne matche → créer un nouvel `AwardType` dynamiquement en extrayant le préfixe commun du nom (ex: "Screen Actors Guild Award for Outstanding Performance..." → type "Screen Actors Guild Award")
|
||||||
|
- Créer l'entité `Award` (name, year, actor, awardType)
|
||||||
|
5. Passer `actor.awardsImported = true`
|
||||||
|
6. Flush
|
||||||
|
|
||||||
|
### Gestion d'erreur
|
||||||
|
|
||||||
|
Si Wikidata est indisponible ou retourne une erreur :
|
||||||
|
- Ne **pas** mettre `awardsImported = true`
|
||||||
|
- L'import du film continue normalement (les awards seront retentés au prochain import contenant cet acteur)
|
||||||
|
- Log de l'erreur
|
||||||
|
|
||||||
|
## Génération des indices (hints)
|
||||||
|
|
||||||
|
### Changements dans `GameGridGenerator`
|
||||||
|
|
||||||
|
**Avant** : appel à `WikidataGateway::getAwards()` à la volée pour chaque acteur du grid.
|
||||||
|
|
||||||
|
**Après** :
|
||||||
|
1. Pour un hint de type "award", requêter les `Award` en BDD pour l'acteur
|
||||||
|
2. Si l'acteur a des awards → en choisir un au hasard
|
||||||
|
3. Si l'acteur n'a pas d'awards → fallback sur les types "film" ou "character" (comportement existant quand Wikidata échouait)
|
||||||
|
|
||||||
|
### Stockage du hint
|
||||||
|
|
||||||
|
- `GameRow.hintData` stocke l'**ID de l'`Award`** (au lieu d'une string brute comme avant)
|
||||||
|
- `resolveHintText()` récupère le nom complet + année depuis l'entité `Award`
|
||||||
|
|
||||||
|
### Pas de filtrage par `AwardType`
|
||||||
|
|
||||||
|
Pour cette itération, on pioche dans tous les awards de l'acteur sans filtrer par type. Le filtrage (choix du joueur : "mode Oscars", "mode Golden Globes"...) sera ajouté ultérieurement.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- UI de choix du type de récompense avant la partie
|
||||||
|
- Filtrage des awards par type lors de la génération des indices
|
||||||
|
- Commande de re-sync des awards pour les acteurs déjà importés
|
||||||
119
docs/superpowers/specs/2026-04-01-game-config-panel-design.md
Normal file
119
docs/superpowers/specs/2026-04-01-game-config-panel-design.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Game Configuration Panel
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a configuration panel above the "Commencer une partie" button on the homepage, allowing players to customize game generation parameters before starting.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### Films vus (toggle on/off)
|
||||||
|
|
||||||
|
- **Visible only** for authenticated users
|
||||||
|
- Default: off
|
||||||
|
- When enabled: all actors in the grid (main actor + hint actors) must appear in at least one film marked as watched by the user
|
||||||
|
- When disabled: no filtering, any actor can appear
|
||||||
|
|
||||||
|
### Paramètres des indices (checkboxes)
|
||||||
|
|
||||||
|
Three checkboxes controlling which hint types can appear in the grid:
|
||||||
|
|
||||||
|
- **Film** (default: checked) — hint shows a film the actor appeared in
|
||||||
|
- **Rôle** (default: checked) — hint shows a character name the actor played
|
||||||
|
- **Récompense** (default: checked) — hint shows an award the actor received
|
||||||
|
|
||||||
|
**Constraint:** at least one must remain checked. The UI prevents unchecking the last one.
|
||||||
|
|
||||||
|
### Récompenses (multi-select checkboxes)
|
||||||
|
|
||||||
|
- **Visible only** when "Récompense" is checked
|
||||||
|
- Lists all `AwardType` entities that have 5+ distinct actors in the database
|
||||||
|
- A "Toutes les récompenses" option at the top that toggles all
|
||||||
|
- Default: all checked (= "Toutes" checked)
|
||||||
|
- Unchecking an individual item unchecks "Toutes"; rechecking all rechecks "Toutes"
|
||||||
|
- Selected AwardType IDs are sent as `award_types[]` in the form POST
|
||||||
|
|
||||||
|
## UI Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Films vus [ toggle ] │ ← auth users only
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ Paramètres des indices │
|
||||||
|
│ │
|
||||||
|
│ ☑ Film ☑ Rôle ☑ Récompense│
|
||||||
|
│ │
|
||||||
|
│ Récompenses : │ ← visible if Récompense checked
|
||||||
|
│ ┌───────────────────────┐ │
|
||||||
|
│ │ ☑ Toutes les récomp. │ │
|
||||||
|
│ │ ☑ Academy Awards │ │
|
||||||
|
│ │ ☑ Golden Globes │ │
|
||||||
|
│ │ ☑ César │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └───────────────────────┘ │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ [ Commencer une partie ] │
|
||||||
|
│ │
|
||||||
|
│ Connectez-vous pour importer...│
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Fields
|
||||||
|
|
||||||
|
All parameters are fields within the existing start game `<form>`:
|
||||||
|
|
||||||
|
| Field name | Type | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| `watched_only` | hidden + checkbox | `"1"` if checked, absent otherwise |
|
||||||
|
| `hint_film` | checkbox | `"1"` if checked |
|
||||||
|
| `hint_character` | checkbox | `"1"` if checked |
|
||||||
|
| `hint_award` | checkbox | `"1"` if checked |
|
||||||
|
| `award_types[]` | checkbox (multiple) | Array of AwardType IDs |
|
||||||
|
|
||||||
|
## Backend Changes
|
||||||
|
|
||||||
|
### HomepageController
|
||||||
|
|
||||||
|
- Query `AwardTypeRepository::findWithMinActors(5)` to get eligible AwardType list
|
||||||
|
- Pass to template for rendering the multi-select
|
||||||
|
|
||||||
|
### GameController (POST /game/start)
|
||||||
|
|
||||||
|
- Extract config parameters from the Request
|
||||||
|
- Pass config to `GameGridProvider::generate()`
|
||||||
|
|
||||||
|
### GameGridProvider::generate()
|
||||||
|
|
||||||
|
Receives a config array/object with:
|
||||||
|
|
||||||
|
- `watchedOnly` (bool) — filter actors to those in user's watched films
|
||||||
|
- `hintTypes` (array) — subset of `['film', 'character', 'award']` based on checked boxes
|
||||||
|
- `awardTypeIds` (array|null) — list of selected AwardType IDs, null = all
|
||||||
|
|
||||||
|
Generation logic changes:
|
||||||
|
|
||||||
|
- **Actor selection:** if `watchedOnly` is true, only pick actors with a MovieRole in a film watched by the authenticated user
|
||||||
|
- **Hint selection:** only use hint types present in `hintTypes`
|
||||||
|
- **Award hints:** only pick awards whose AwardType ID is in `awardTypeIds`
|
||||||
|
- **Retry mechanism:** if generation fails (cannot find a valid hint for every row), retry up to 5 times. After 5 failures, redirect to homepage with a flash error message explaining the grid could not be generated with the chosen parameters.
|
||||||
|
|
||||||
|
### AwardTypeRepository
|
||||||
|
|
||||||
|
New method: `findWithMinActors(int $minActors): array` — returns AwardType entities having at least `$minActors` distinct actors.
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
### Stimulus controller: `game-config`
|
||||||
|
|
||||||
|
Registered on the config panel container. Handles:
|
||||||
|
|
||||||
|
1. **Award section visibility:** show/hide the AwardType checkbox list when "Récompense" is toggled
|
||||||
|
2. **Minimum one hint type:** prevent unchecking the last remaining hint checkbox
|
||||||
|
3. **"Toutes les récompenses" toggle:** check/uncheck all AwardType checkboxes; sync state when individual items change
|
||||||
|
|
||||||
|
### CSS (in app.css)
|
||||||
|
|
||||||
|
- **Toggle switch:** CSS-only using `appearance: none` on checkbox, `::before` pseudo-element for the sliding circle, `--orange` when active, `--border` when inactive
|
||||||
|
- **Hint checkboxes:** `accent-color: var(--orange)`
|
||||||
|
- **AwardType list:** border `var(--border)`, `border-radius: var(--radius-sm)`, `max-height: 150px`, `overflow-y: auto`
|
||||||
|
- **Section labels:** `color: var(--text-muted)`, `font-size: 13px`, `text-transform: uppercase`
|
||||||
|
- No new CSS or JS files — everything in existing `app.css` and a new Stimulus controller file
|
||||||
38
migrations/Version20260329000001.php
Normal file
38
migrations/Version20260329000001.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?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 Version20260329000001 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_movie (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, movie_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_A6B68B33A76ED395 ON user_movie (user_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_A6B68B338F93B6FC ON user_movie (movie_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX user_movie_unique ON user_movie (user_id, movie_id)');
|
||||||
|
$this->addSql('ALTER TABLE user_movie ADD CONSTRAINT FK_A6B68B33A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE user_movie ADD CONSTRAINT FK_A6B68B338F93B6FC FOREIGN KEY (movie_id) REFERENCES movie (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE user_movie DROP CONSTRAINT FK_A6B68B33A76ED395');
|
||||||
|
$this->addSql('ALTER TABLE user_movie DROP CONSTRAINT FK_A6B68B338F93B6FC');
|
||||||
|
$this->addSql('DROP TABLE user_movie');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
migrations/Version20260329000002.php
Normal file
36
migrations/Version20260329000002.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?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 Version20260329000002 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 import (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, file_path VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, total_batches INT NOT NULL, processed_batches INT NOT NULL, total_films INT NOT NULL, failed_films INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_9D4ECE1DA76ED395 ON import (user_id)');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.completed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE import ADD CONSTRAINT FK_9D4ECE1DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE import DROP CONSTRAINT FK_9D4ECE1DA76ED395');
|
||||||
|
$this->addSql('DROP TABLE import');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260329000003.php
Normal file
35
migrations/Version20260329000003.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260329000003 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 notification (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, message VARCHAR(255) NOT NULL, is_read BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_BF5476CAA76ED395 ON notification (user_id)');
|
||||||
|
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395');
|
||||||
|
$this->addSql('DROP TABLE notification');
|
||||||
|
}
|
||||||
|
}
|
||||||
57
migrations/Version20260330174355.php
Normal file
57
migrations/Version20260330174355.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?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 Version20260330174355 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 game (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, status VARCHAR(20) NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, user_id INT DEFAULT NULL, main_actor_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_232B318CA76ED395 ON game (user_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_232B318CC9F8E33F ON game (main_actor_id)');
|
||||||
|
$this->addSql('CREATE TABLE game_row (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, position INT NOT NULL, row_order INT NOT NULL, game_id INT NOT NULL, actor_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_9F6AE51EE48FD905 ON game_row (game_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_9F6AE51E10DAF24A ON game_row (actor_id)');
|
||||||
|
$this->addSql('ALTER TABLE game ADD CONSTRAINT FK_232B318CA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE game ADD CONSTRAINT FK_232B318CC9F8E33F FOREIGN KEY (main_actor_id) REFERENCES actor (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE game_row ADD CONSTRAINT FK_9F6AE51EE48FD905 FOREIGN KEY (game_id) REFERENCES game (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE game_row ADD CONSTRAINT FK_9F6AE51E10DAF24A FOREIGN KEY (actor_id) REFERENCES actor (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.completed_at IS \'\'');
|
||||||
|
$this->addSql('ALTER TABLE notification ALTER is_read DROP DEFAULT');
|
||||||
|
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'\'');
|
||||||
|
$this->addSql('ALTER INDEX idx_a6b68b33a76ed395 RENAME TO IDX_FF9C0937A76ED395');
|
||||||
|
$this->addSql('ALTER INDEX idx_a6b68b338f93b6fc RENAME TO IDX_FF9C09378F93B6FC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE game DROP CONSTRAINT FK_232B318CA76ED395');
|
||||||
|
$this->addSql('ALTER TABLE game DROP CONSTRAINT FK_232B318CC9F8E33F');
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP CONSTRAINT FK_9F6AE51EE48FD905');
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP CONSTRAINT FK_9F6AE51E10DAF24A');
|
||||||
|
$this->addSql('DROP TABLE game');
|
||||||
|
$this->addSql('DROP TABLE game_row');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.completed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE notification ALTER is_read SET DEFAULT false');
|
||||||
|
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER INDEX idx_ff9c09378f93b6fc RENAME TO idx_a6b68b338f93b6fc');
|
||||||
|
$this->addSql('ALTER INDEX idx_ff9c0937a76ed395 RENAME TO idx_a6b68b33a76ed395');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/Version20260330203017.php
Normal file
33
migrations/Version20260330203017.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?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 Version20260330203017 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('ALTER TABLE game_row ADD hint_type VARCHAR(20) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE game_row ADD hint_data VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP hint_type');
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP hint_data');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260331000001.php
Normal file
35
migrations/Version20260331000001.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260331000001 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Drop notification table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS notification');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE notification (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL REFERENCES "user"(id),
|
||||||
|
message VARCHAR(255) NOT NULL,
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260331000002.php
Normal file
26
migrations/Version20260331000002.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260331000002 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add year column to movie table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE movie ADD year INT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE movie DROP COLUMN year');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
migrations/Version20260401000001.php
Normal file
32
migrations/Version20260401000001.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260401000001 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add award_type, award tables and actor.awards_imported column';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE award_type (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, pattern VARCHAR(255) NOT NULL)');
|
||||||
|
$this->addSql('CREATE TABLE award (id SERIAL PRIMARY KEY, award_type_id INT NOT NULL, actor_id INT NOT NULL, name VARCHAR(255) NOT NULL, year INT DEFAULT NULL, CONSTRAINT fk_award_award_type FOREIGN KEY (award_type_id) REFERENCES award_type (id), CONSTRAINT fk_award_actor FOREIGN KEY (actor_id) REFERENCES actor (id))');
|
||||||
|
$this->addSql('CREATE INDEX idx_award_award_type ON award (award_type_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_award_actor ON award (actor_id)');
|
||||||
|
$this->addSql('ALTER TABLE actor ADD awards_imported BOOLEAN NOT NULL DEFAULT false');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE award');
|
||||||
|
$this->addSql('DROP TABLE award_type');
|
||||||
|
$this->addSql('ALTER TABLE actor DROP COLUMN awards_imported');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
migrations/Version20260401000002.php
Normal file
30
migrations/Version20260401000002.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260401000002 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add processed_films column to import table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE import ADD processed_films INT NOT NULL DEFAULT 0');
|
||||||
|
$this->addSql('ALTER TABLE import DROP COLUMN total_batches');
|
||||||
|
$this->addSql('ALTER TABLE import DROP COLUMN processed_batches');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE import DROP COLUMN processed_films');
|
||||||
|
$this->addSql('ALTER TABLE import ADD total_batches INT NOT NULL DEFAULT 0');
|
||||||
|
$this->addSql('ALTER TABLE import ADD processed_batches INT NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "ltbxd-actorle",
|
"name": "ltbxd-actorle",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27",
|
"@floating-ui/react": "^0.27",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@hotwired/stimulus": "^3.2",
|
"@hotwired/stimulus": "^3.2",
|
||||||
"@hotwired/turbo": "^7.3",
|
"@hotwired/turbo": "^7.3",
|
||||||
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
||||||
@@ -952,6 +953,15 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==",
|
||||||
|
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hotwired/stimulus": {
|
"node_modules/@hotwired/stimulus": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27",
|
"@floating-ui/react": "^0.27",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@hotwired/stimulus": "^3.2",
|
"@hotwired/stimulus": "^3.2",
|
||||||
"@hotwired/turbo": "^7.3",
|
"@hotwired/turbo": "^7.3",
|
||||||
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Command;
|
|
||||||
|
|
||||||
use App\Entity\Actor;
|
|
||||||
use App\Entity\Movie;
|
|
||||||
use App\Entity\MovieRole;
|
|
||||||
use App\Exception\GatewayException;
|
|
||||||
use App\Gateway\TMDBGateway;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
#[AsCommand('app:sync-actors')]
|
|
||||||
readonly class SyncActorsCommand
|
|
||||||
{
|
|
||||||
public function __construct (
|
|
||||||
private TMDBGateway $TMDBGateway,
|
|
||||||
private EntityManagerInterface $em,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(OutputInterface $output): int
|
|
||||||
{
|
|
||||||
foreach ($this->em->getRepository(Movie::class)->findAll() as $film) {
|
|
||||||
try {
|
|
||||||
$creditsContext = $this->TMDBGateway->getMovieCredits($film->getTmdbId());
|
|
||||||
} catch (GatewayException $e) {
|
|
||||||
$output->writeln('/!\ '.$e->getMessage());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($creditsContext->cast)) {
|
|
||||||
$output->writeln('Syncing cast for '.$film->getTitle());
|
|
||||||
}
|
|
||||||
foreach ($creditsContext->cast as $actorModel) {
|
|
||||||
// Get existing or create new
|
|
||||||
$actor = $this->em->getRepository(Actor::class)->findOneBy(['tmdbId' => $actorModel->id]);
|
|
||||||
if (!$actor instanceof Actor) {
|
|
||||||
$output->writeln('* New actor found: '.$actorModel->name);
|
|
||||||
|
|
||||||
$actor = new Actor()
|
|
||||||
->setPopularity($actorModel->popularity)
|
|
||||||
->setName($actorModel->name)
|
|
||||||
->setTmdbId($actorModel->id)
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->em->persist($actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create the role
|
|
||||||
if (0 < $this->em->getRepository(MovieRole::class)->count(['actor' => $actor, 'movie' => $film])) {
|
|
||||||
$actor->addMovieRole(new MovieRole()
|
|
||||||
->setMovie($film)
|
|
||||||
->setCharacter($actorModel->character)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Command;
|
|
||||||
|
|
||||||
use App\Entity\Movie;
|
|
||||||
use App\Exception\GatewayException;
|
|
||||||
use App\Gateway\LtbxdGateway;
|
|
||||||
use App\Gateway\TMDBGateway;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
#[AsCommand('app:sync-films')]
|
|
||||||
readonly class SyncFilmsCommands
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private LtbxdGateway $ltbxdGateway,
|
|
||||||
private TMDBGateway $TMDBGateway,
|
|
||||||
private EntityManagerInterface $em,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(OutputInterface $output): int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$ltbxdMovies = $this->ltbxdGateway->parseFile();
|
|
||||||
} catch (GatewayException $e) {
|
|
||||||
$output->writeln('/!\ '.$e->getMessage());
|
|
||||||
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$i = 0;
|
|
||||||
foreach ($ltbxdMovies as $ltbxdMovie) {
|
|
||||||
// If the movie already exists, skip
|
|
||||||
if (0 < $this->em->getRepository(Movie::class)->count(['ltbxdRef' => $ltbxdMovie->getLtbxdRef()])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search movie on TMDB
|
|
||||||
try {
|
|
||||||
$film = $this->TMDBGateway->searchMovie($ltbxdMovie->getName());
|
|
||||||
} catch (GatewayException $e) {
|
|
||||||
$output->writeln('/!\ '.$e->getMessage());
|
|
||||||
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($film) {
|
|
||||||
$output->writeln('* Found '.$ltbxdMovie->getName());
|
|
||||||
|
|
||||||
$filmEntity = new Movie()
|
|
||||||
->setLtbxdRef($ltbxdMovie->getLtbxdRef())
|
|
||||||
->setTitle($ltbxdMovie->getName())
|
|
||||||
->setTmdbId($film->getId())
|
|
||||||
;
|
|
||||||
$this->em->persist($filmEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
++$i;
|
|
||||||
if (0 === $i % 50) {
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$output->writeln('Films synced');
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
127
src/Controller/GameController.php
Normal file
127
src/Controller/GameController.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use App\Provider\GameGridProvider;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class GameController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
|
||||||
|
public function start(
|
||||||
|
Request $request,
|
||||||
|
GameGridProvider $generator,
|
||||||
|
GameRepository $gameRepository,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_start', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Check no game already in progress
|
||||||
|
if ($user) {
|
||||||
|
$existing = $gameRepository->findActiveForUser($user);
|
||||||
|
} else {
|
||||||
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
|
$existing = $gameId ? $gameRepository->find($gameId) : null;
|
||||||
|
if ($existing && $existing->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$existing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config from form parameters
|
||||||
|
$config = [];
|
||||||
|
|
||||||
|
if ($user && $request->request->getBoolean('watched_only')) {
|
||||||
|
$config['watchedOnly'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hintTypes = [];
|
||||||
|
if ($request->request->getBoolean('hint_film', true)) {
|
||||||
|
$hintTypes[] = 'film';
|
||||||
|
}
|
||||||
|
if ($request->request->getBoolean('hint_character', true)) {
|
||||||
|
$hintTypes[] = 'character';
|
||||||
|
}
|
||||||
|
if ($request->request->getBoolean('hint_award', true)) {
|
||||||
|
$hintTypes[] = 'award';
|
||||||
|
}
|
||||||
|
if (empty($hintTypes)) {
|
||||||
|
$hintTypes = ['film', 'character', 'award'];
|
||||||
|
}
|
||||||
|
$config['hintTypes'] = $hintTypes;
|
||||||
|
|
||||||
|
/** @var list<string> $awardTypeIds */
|
||||||
|
$awardTypeIds = $request->request->all('award_types');
|
||||||
|
if (!empty($awardTypeIds) && in_array('award', $hintTypes)) {
|
||||||
|
$config['awardTypeIds'] = array_map('intval', $awardTypeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = $generator->generate($user, $config);
|
||||||
|
|
||||||
|
if ($game === null) {
|
||||||
|
$this->addFlash('error', 'Impossible de générer une grille avec ces paramètres. Essayez avec des critères moins restrictifs.');
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->set('current_game_id', $game->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/game/{id}/abandon', name: 'app_game_abandon', methods: ['POST'])]
|
||||||
|
public function abandon(
|
||||||
|
Game $game,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_abandon', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if ($user) {
|
||||||
|
if ($game->getUser() !== $user) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$sessionGameId = $request->getSession()->get('current_game_id');
|
||||||
|
if ($game->getId() !== $sessionGameId) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$game->abandon();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateCsrfToken(string $tokenId, Request $request): void
|
||||||
|
{
|
||||||
|
$token = $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid($tokenId, $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,77 +4,57 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Gateway\TMDBGateway;
|
use App\Entity\Game;
|
||||||
use App\Repository\ActorRepository;
|
use App\Entity\User;
|
||||||
use App\Repository\MovieRepository;
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use App\Provider\GameGridProvider;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
|
||||||
|
|
||||||
class HomepageController extends AbstractController
|
class HomepageController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly ActorRepository $actorRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
#[Route('/', name: 'app_homepage')]
|
#[Route('/', name: 'app_homepage')]
|
||||||
public function index(SerializerInterface $serializer): Response
|
public function index(
|
||||||
{
|
Request $request,
|
||||||
// Final actor to be guessed
|
GameRepository $gameRepository,
|
||||||
$mainActor = $this->actorRepository->findOneRandom(4);
|
GameGridProvider $gridGenerator,
|
||||||
|
AwardTypeRepository $awardTypeRepository,
|
||||||
|
): Response {
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
// Actors for the grid
|
$game = null;
|
||||||
$actors = [];
|
|
||||||
$leftSize = 0;
|
if ($user) {
|
||||||
$rightSize = 0;
|
$game = $gameRepository->findActiveForUser($user);
|
||||||
foreach (str_split(strtolower($mainActor->getName())) as $char) {
|
} else {
|
||||||
if (!preg_match('/[a-z]/', $char)) {
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
continue;
|
if ($gameId) {
|
||||||
|
$game = $gameRepository->find($gameId);
|
||||||
|
if (!$game || $game->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
$game = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tryFindActor = 0;
|
if (!$game) {
|
||||||
do {
|
return $this->render('homepage/index.html.twig', [
|
||||||
$actor = $this->actorRepository->findOneRandom(4, $char);
|
'game' => null,
|
||||||
++$tryFindActor;
|
'awardTypes' => $awardTypeRepository->findWithMinActors(5),
|
||||||
} while (
|
]);
|
||||||
$actor === $mainActor
|
|
||||||
|| in_array($actor, array_map(fn ($actorMap) => $actorMap['actor'], $actors))
|
|
||||||
|| $tryFindActor < 5
|
|
||||||
);
|
|
||||||
|
|
||||||
$actorData = [
|
|
||||||
'actor' => $actor,
|
|
||||||
'pos' => strpos($actor->getName(), $char),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($leftSize < $actorData['pos']) {
|
|
||||||
$leftSize = $actorData['pos'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$rightSizeActor = strlen($actor->getName()) - $actorData['pos'] - 1;
|
$gridData = $gridGenerator->computeGridData($game);
|
||||||
if ($rightSize < $rightSizeActor) {
|
|
||||||
$rightSize = $rightSizeActor;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actors[] = $actorData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predict grid size
|
|
||||||
$width = $rightSize + $leftSize + 1;
|
|
||||||
$middle = $leftSize;
|
|
||||||
|
|
||||||
// Build JSON-serializable grid for React
|
|
||||||
$grid = array_map(fn (array $actorData) => [
|
|
||||||
'actorName' => $actorData['actor']->getName(),
|
|
||||||
'actorId' => $actorData['actor']->getId(),
|
|
||||||
'pos' => $actorData['pos'],
|
|
||||||
], $actors);
|
|
||||||
|
|
||||||
return $this->render('homepage/index.html.twig', [
|
return $this->render('homepage/index.html.twig', [
|
||||||
'grid' => $grid,
|
'game' => $game,
|
||||||
'width' => $width,
|
'grid' => $gridData['grid'],
|
||||||
'middle' => $middle,
|
'width' => $gridData['width'],
|
||||||
|
'middle' => $gridData['middle'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
src/Controller/ImportController.php
Normal file
95
src/Controller/ImportController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Import;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Message\ProcessImportMessage;
|
||||||
|
use App\Repository\ImportRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
class ImportController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/api/imports/latest', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function latest(ImportRepository $importRepository): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$import = $importRepository->findLatestForUser($user);
|
||||||
|
|
||||||
|
if (!$import) {
|
||||||
|
return $this->json(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'id' => $import->getId(),
|
||||||
|
'status' => $import->getStatus(),
|
||||||
|
'totalFilms' => $import->getTotalFilms(),
|
||||||
|
'processedFilms' => $import->getProcessedFilms(),
|
||||||
|
'failedFilms' => $import->getFailedFilms(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/imports', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function create(
|
||||||
|
Request $request,
|
||||||
|
FilesystemOperator $defaultStorage,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
ImportRepository $importRepository,
|
||||||
|
MessageBusInterface $bus,
|
||||||
|
): JsonResponse {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
if ($importRepository->hasActiveImport($user)) {
|
||||||
|
return $this->json(['error' => 'Un import est déjà en cours.'], Response::HTTP_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $request->files->get('file');
|
||||||
|
|
||||||
|
if (!$file) {
|
||||||
|
return $this->json(['error' => 'No file provided.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('csv' !== $file->getClientOriginalExtension()) {
|
||||||
|
return $this->json(['error' => 'Only CSV files are accepted.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file->getSize() > 5 * 1024 * 1024) {
|
||||||
|
return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$import = new Import();
|
||||||
|
$import->setUser($user);
|
||||||
|
$import->setFilePath('pending');
|
||||||
|
|
||||||
|
$em->persist($import);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$filePath = sprintf('imports/%d/%d.csv', $user->getId(), $import->getId());
|
||||||
|
$defaultStorage->write($filePath, file_get_contents($file->getPathname()));
|
||||||
|
|
||||||
|
$import->setFilePath($filePath);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$bus->dispatch(new ProcessImportMessage($import->getId()));
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'id' => $import->getId(),
|
||||||
|
'status' => $import->getStatus(),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,9 +30,17 @@ class Actor
|
|||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
private ?int $tmdbId = null;
|
private ?int $tmdbId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
private bool $awardsImported = false;
|
||||||
|
|
||||||
|
/** @var Collection<int, Award> */
|
||||||
|
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'actor')]
|
||||||
|
private Collection $awards;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->movieRoles = new ArrayCollection();
|
$this->movieRoles = new ArrayCollection();
|
||||||
|
$this->awards = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -105,4 +113,22 @@ class Actor
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isAwardsImported(): bool
|
||||||
|
{
|
||||||
|
return $this->awardsImported;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAwardsImported(bool $awardsImported): static
|
||||||
|
{
|
||||||
|
$this->awardsImported = $awardsImported;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Award> */
|
||||||
|
public function getAwards(): Collection
|
||||||
|
{
|
||||||
|
return $this->awards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/Entity/Award.php
Normal file
84
src/Entity/Award.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\AwardRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: AwardRepository::class)]
|
||||||
|
class Award
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: AwardType::class, inversedBy: 'awards')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private AwardType $awardType;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class, inversedBy: 'awards')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private Actor $actor;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $year = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAwardType(): AwardType
|
||||||
|
{
|
||||||
|
return $this->awardType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAwardType(AwardType $awardType): static
|
||||||
|
{
|
||||||
|
$this->awardType = $awardType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): Actor
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActor(Actor $actor): static
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getYear(): ?int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(?int $year): static
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/Entity/AwardType.php
Normal file
69
src/Entity/AwardType.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: AwardTypeRepository::class)]
|
||||||
|
class AwardType
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $pattern;
|
||||||
|
|
||||||
|
/** @var Collection<int, Award> */
|
||||||
|
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'awardType')]
|
||||||
|
private Collection $awards;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->awards = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPattern(): string
|
||||||
|
{
|
||||||
|
return $this->pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPattern(string $pattern): static
|
||||||
|
{
|
||||||
|
$this->pattern = $pattern;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Award> */
|
||||||
|
public function getAwards(): Collection
|
||||||
|
{
|
||||||
|
return $this->awards;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/Entity/Game.php
Normal file
132
src/Entity/Game.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRepository::class)]
|
||||||
|
class Game
|
||||||
|
{
|
||||||
|
public const string STATUS_IN_PROGRESS = 'in_progress';
|
||||||
|
public const string STATUS_ABANDONED = 'abandoned';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $mainActor = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $status = self::STATUS_IN_PROGRESS;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $startedAt;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $endedAt = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, GameRow> */
|
||||||
|
#[ORM\OneToMany(targetEntity: GameRow::class, mappedBy: 'game', cascade: ['persist'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['rowOrder' => 'ASC'])]
|
||||||
|
private Collection $rows;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->startedAt = new \DateTimeImmutable();
|
||||||
|
$this->rows = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMainActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->mainActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMainActor(Actor $mainActor): static
|
||||||
|
{
|
||||||
|
$this->mainActor = $mainActor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndedAt(?\DateTimeImmutable $endedAt): static
|
||||||
|
{
|
||||||
|
$this->endedAt = $endedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, GameRow> */
|
||||||
|
public function getRows(): Collection
|
||||||
|
{
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addRow(GameRow $row): static
|
||||||
|
{
|
||||||
|
if (!$this->rows->contains($row)) {
|
||||||
|
$this->rows->add($row);
|
||||||
|
$row->setGame($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abandon(): static
|
||||||
|
{
|
||||||
|
$this->status = self::STATUS_ABANDONED;
|
||||||
|
$this->endedAt = new \DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/Entity/GameRow.php
Normal file
114
src/Entity/GameRow.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRowRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRowRepository::class)]
|
||||||
|
class GameRow
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Game::class, inversedBy: 'rows')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Game $game = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $actor = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $position;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $rowOrder;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
private ?string $hintType = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $hintData = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGame(): ?Game
|
||||||
|
{
|
||||||
|
return $this->game;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGame(Game $game): static
|
||||||
|
{
|
||||||
|
$this->game = $game;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActor(Actor $actor): static
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRowOrder(): int
|
||||||
|
{
|
||||||
|
return $this->rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRowOrder(int $rowOrder): static
|
||||||
|
{
|
||||||
|
$this->rowOrder = $rowOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHintType(): ?string
|
||||||
|
{
|
||||||
|
return $this->hintType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHintType(string $hintType): static
|
||||||
|
{
|
||||||
|
$this->hintType = $hintType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHintData(): ?string
|
||||||
|
{
|
||||||
|
return $this->hintData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHintData(string $hintData): static
|
||||||
|
{
|
||||||
|
$this->hintData = $hintData;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/Entity/Import.php
Normal file
139
src/Entity/Import.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\ImportRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ImportRepository::class)]
|
||||||
|
class Import
|
||||||
|
{
|
||||||
|
public const string STATUS_PENDING = 'pending';
|
||||||
|
public const string STATUS_PROCESSING = 'processing';
|
||||||
|
public const string STATUS_COMPLETED = 'completed';
|
||||||
|
public const string STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $filePath = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $status = self::STATUS_PENDING;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $totalFilms = 0;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $processedFilms = 0;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $failedFilms = 0;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $completedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilePath(): ?string
|
||||||
|
{
|
||||||
|
return $this->filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFilePath(string $filePath): static
|
||||||
|
{
|
||||||
|
$this->filePath = $filePath;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProcessedFilms(): int
|
||||||
|
{
|
||||||
|
return $this->processedFilms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProcessedFilms(int $processedFilms): static
|
||||||
|
{
|
||||||
|
$this->processedFilms = $processedFilms;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotalFilms(): int
|
||||||
|
{
|
||||||
|
return $this->totalFilms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTotalFilms(int $totalFilms): static
|
||||||
|
{
|
||||||
|
$this->totalFilms = $totalFilms;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFailedFilms(): int
|
||||||
|
{
|
||||||
|
return $this->failedFilms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFailedFilms(int $failedFilms): static
|
||||||
|
{
|
||||||
|
$this->failedFilms = $failedFilms;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCompletedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->completedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCompletedAt(?\DateTimeImmutable $completedAt): static
|
||||||
|
{
|
||||||
|
$this->completedAt = $completedAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ class Movie
|
|||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $title = null;
|
private ?string $title = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $year = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, MovieRole>
|
* @var Collection<int, MovieRole>
|
||||||
*/
|
*/
|
||||||
@@ -76,6 +79,18 @@ class Movie
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getYear(): ?int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(?int $year): static
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, MovieRole>
|
* @return Collection<int, MovieRole>
|
||||||
*/
|
*/
|
||||||
|
|||||||
55
src/Entity/UserMovie.php
Normal file
55
src/Entity/UserMovie.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\UserMovieRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: UserMovieRepository::class)]
|
||||||
|
#[ORM\UniqueConstraint(name: 'user_movie_unique', columns: ['user_id', 'movie_id'])]
|
||||||
|
class UserMovie
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Movie::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Movie $movie = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMovie(): ?Movie
|
||||||
|
{
|
||||||
|
return $this->movie;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMovie(?Movie $movie): static
|
||||||
|
{
|
||||||
|
$this->movie = $movie;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/EventListener/AbandonAnonymousGameOnLoginListener.php
Normal file
39
src/EventListener/AbandonAnonymousGameOnLoginListener.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||||
|
|
||||||
|
#[AsEventListener]
|
||||||
|
class AbandonAnonymousGameOnLoginListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GameRepository $gameRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(LoginSuccessEvent $event): void
|
||||||
|
{
|
||||||
|
$session = $event->getRequest()->getSession();
|
||||||
|
$gameId = $session->get('current_game_id');
|
||||||
|
|
||||||
|
if (!$gameId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = $this->gameRepository->find($gameId);
|
||||||
|
|
||||||
|
if ($game && $game->getStatus() === Game::STATUS_IN_PROGRESS) {
|
||||||
|
$game->abandon();
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->remove('current_game_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,13 @@ readonly class LtbxdGateway
|
|||||||
* @return LtbxdMovie[]
|
* @return LtbxdMovie[]
|
||||||
* @throws GatewayException
|
* @throws GatewayException
|
||||||
*/
|
*/
|
||||||
public function parseFile(): array
|
public function parseFileFromPath(string $path): array
|
||||||
{
|
{
|
||||||
if (!file_exists($this->fileDir)) {
|
if (!file_exists($path)) {
|
||||||
throw new GatewayException(sprintf('Could not find file %s', $this->fileDir));
|
throw new GatewayException(sprintf('Could not find file %s', $path));
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileContent = file_get_contents($this->fileDir);
|
$fileContent = file_get_contents($path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv');
|
return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv');
|
||||||
@@ -34,4 +34,13 @@ readonly class LtbxdGateway
|
|||||||
throw new GatewayException('Error while deserializing Letterboxd data', previous: $e);
|
throw new GatewayException('Error while deserializing Letterboxd data', previous: $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return LtbxdMovie[]
|
||||||
|
* @throws GatewayException
|
||||||
|
*/
|
||||||
|
public function parseFile(): array
|
||||||
|
{
|
||||||
|
return $this->parseFileFromPath($this->fileDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/Gateway/WikidataGateway.php
Normal file
111
src/Gateway/WikidataGateway.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Gateway;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class WikidataGateway
|
||||||
|
{
|
||||||
|
private const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch awards for an actor from Wikidata.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, year: int}>
|
||||||
|
*/
|
||||||
|
public function getAwards(Actor $actor): array
|
||||||
|
{
|
||||||
|
return $this->getAwardsForActors([$actor])[$actor->getName()] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch awards for multiple actors in a single SPARQL query.
|
||||||
|
*
|
||||||
|
* @param list<Actor> $actors
|
||||||
|
*
|
||||||
|
* @return array<string, list<array{name: string, year: int}>>
|
||||||
|
*/
|
||||||
|
public function getAwardsForActors(array $actors): array
|
||||||
|
{
|
||||||
|
if ([] === $actors) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sparql = $this->buildBatchQuery($actors);
|
||||||
|
|
||||||
|
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
|
||||||
|
'query' => [
|
||||||
|
'query' => $sparql,
|
||||||
|
'format' => 'json',
|
||||||
|
],
|
||||||
|
'headers' => [
|
||||||
|
'Accept' => 'application/sparql-results+json',
|
||||||
|
'User-Agent' => 'LtbxdActorle/1.0',
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
$awards = [];
|
||||||
|
|
||||||
|
foreach ($data['results']['bindings'] ?? [] as $binding) {
|
||||||
|
$actorName = $binding['name']['value'] ?? null;
|
||||||
|
$awardName = $binding['awardLabel']['value'] ?? null;
|
||||||
|
$year = $binding['year']['value'] ?? null;
|
||||||
|
|
||||||
|
if ($actorName && $awardName && $year) {
|
||||||
|
$awards[$actorName][] = [
|
||||||
|
'name' => $awardName,
|
||||||
|
'year' => (int) substr($year, 0, 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $awards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Actor> $actors
|
||||||
|
*/
|
||||||
|
private function buildBatchQuery(array $actors): string
|
||||||
|
{
|
||||||
|
$values = implode(' ', array_map(function (Actor $actor) {
|
||||||
|
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actor->getName());
|
||||||
|
|
||||||
|
return '"'.$escaped.'"@en';
|
||||||
|
}, $actors));
|
||||||
|
|
||||||
|
return <<<SPARQL
|
||||||
|
SELECT ?name ?awardLabel ?year WHERE {
|
||||||
|
VALUES ?name { {$values} }
|
||||||
|
?person rdfs:label ?name .
|
||||||
|
?person wdt:P31 wd:Q5 .
|
||||||
|
?person p:P166 ?awardStatement .
|
||||||
|
?awardStatement ps:P166 ?award .
|
||||||
|
?awardStatement pq:P585 ?date .
|
||||||
|
BIND(YEAR(?date) AS ?year)
|
||||||
|
|
||||||
|
# Only keep entertainment awards (film, TV, music, theater, performing arts)
|
||||||
|
VALUES ?awardSuperclass {
|
||||||
|
wd:Q4220920 # film award
|
||||||
|
wd:Q1407443 # television award
|
||||||
|
wd:Q2235858 # music award
|
||||||
|
wd:Q15056993 # film festival award
|
||||||
|
wd:Q15383322 # theater award
|
||||||
|
wd:Q29461289 # performing arts award
|
||||||
|
}
|
||||||
|
?award wdt:P31/wdt:P279* ?awardSuperclass .
|
||||||
|
|
||||||
|
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
|
||||||
|
}
|
||||||
|
ORDER BY ?name DESC(?year)
|
||||||
|
SPARQL;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Import/ActorSyncer.php
Normal file
52
src/Import/ActorSyncer.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Movie;
|
||||||
|
use App\Entity\MovieRole;
|
||||||
|
use App\Exception\GatewayException;
|
||||||
|
use App\Gateway\TMDBGateway;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
readonly class ActorSyncer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TMDBGateway $tmdbGateway,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch credits from TMDB for the given movie and create missing Actor/MovieRole entries.
|
||||||
|
*
|
||||||
|
* @throws GatewayException
|
||||||
|
*/
|
||||||
|
public function syncActorsForMovie(Movie $movie): void
|
||||||
|
{
|
||||||
|
$creditsContext = $this->tmdbGateway->getMovieCredits($movie->getTmdbId());
|
||||||
|
|
||||||
|
foreach ($creditsContext->cast as $actorModel) {
|
||||||
|
$actor = $this->em->getRepository(Actor::class)->findOneBy(['tmdbId' => $actorModel->id]);
|
||||||
|
if (!$actor instanceof Actor) {
|
||||||
|
$actor = new Actor()
|
||||||
|
->setPopularity($actorModel->popularity)
|
||||||
|
->setName($actorModel->name)
|
||||||
|
->setTmdbId($actorModel->id);
|
||||||
|
|
||||||
|
$this->em->persist($actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingRole = $this->em->getRepository(MovieRole::class)->count(['actor' => $actor, 'movie' => $movie]);
|
||||||
|
if (0 === $existingRole) {
|
||||||
|
$role = new MovieRole()
|
||||||
|
->setMovie($movie)
|
||||||
|
->setActor($actor)
|
||||||
|
->setCharacter($actorModel->character);
|
||||||
|
|
||||||
|
$this->em->persist($role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/Import/AwardImporter.php
Normal file
231
src/Import/AwardImporter.php
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Award;
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use App\Gateway\WikidataGateway;
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
readonly class AwardImporter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Canonical award name => keywords to match (case-insensitive).
|
||||||
|
* Checked in order — first match wins.
|
||||||
|
*/
|
||||||
|
private const AWARD_MAP = [
|
||||||
|
'Oscar' => ['Academy Award', 'Oscar'],
|
||||||
|
'Golden Globe' => ['Golden Globe', 'Golden Globes'],
|
||||||
|
'BAFTA' => ['BAFTA', 'British Academy Film Award', 'British Academy Television Award', 'British Academy Games Award'],
|
||||||
|
'César' => ['César'],
|
||||||
|
'SAG' => ['Screen Actors Guild'],
|
||||||
|
'Emmy' => ['Emmy Award', 'Primetime Emmy'],
|
||||||
|
'Tony' => ['Tony Award', 'Tony award'],
|
||||||
|
'Grammy' => ['Grammy'],
|
||||||
|
'Cannes' => ['Festival de Cannes', 'Cannes', "Palme d'or", "Caméra d'or"],
|
||||||
|
'Sundance' => ['Sundance'],
|
||||||
|
'Berlinale' => ['Berlinale', 'Berliner Bär', "Ours d'argent", "Ours d'or"],
|
||||||
|
'Mostra de Venise' => ['Mostra', 'Venice Film Festival', 'Coupe Volpi', "Lion d'or"],
|
||||||
|
'Saturn' => ['Saturn Award'],
|
||||||
|
'MTV' => ['MTV Movie', 'MTV Video'],
|
||||||
|
"Critics' Choice" => ["Critics' Choice"],
|
||||||
|
'Independent Spirit' => ['Independent Spirit'],
|
||||||
|
'Annie' => ['Annie Award'],
|
||||||
|
'Goya' => ['prix Goya', 'Goya Award'],
|
||||||
|
'Laurence Olivier' => ['Laurence Olivier', 'Olivier Award'],
|
||||||
|
'David di Donatello' => ['David di Donatello'],
|
||||||
|
'Gotham' => ['Gotham Award', 'Gotham Independent'],
|
||||||
|
'NAACP Image' => ['NAACP Image'],
|
||||||
|
"People's Choice" => ["People's Choice"],
|
||||||
|
'Teen Choice' => ['Teen Choice'],
|
||||||
|
'BET' => ['BET Award', 'BET Her', 'BET YoungStars'],
|
||||||
|
'Black Reel' => ['Black Reel'],
|
||||||
|
'National Board of Review' => ['National Board of Review'],
|
||||||
|
'New York Film Critics Circle' => ['New York Film Critics Circle'],
|
||||||
|
'Los Angeles Film Critics' => ['Los Angeles Film Critics'],
|
||||||
|
'San Sebastián' => ['Donostia', 'San Sebastián'],
|
||||||
|
'Sitges' => ['Sitges'],
|
||||||
|
'Satellite' => ['Satellite Award'],
|
||||||
|
'Lucille Lortel' => ['Lucille Lortel'],
|
||||||
|
'Golden Raspberry' => ['Golden Raspberry', 'Razzie'],
|
||||||
|
'Drama Desk' => ['Drama Desk'],
|
||||||
|
'Genie' => ['Genie Award'],
|
||||||
|
'European Film Award' => ['prix du cinéma européen', 'European Film Award'],
|
||||||
|
'AACTA' => ['AACTA'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keywords indicating non-entertainment awards (case-insensitive).
|
||||||
|
* These slip through even with the SPARQL filter.
|
||||||
|
*/
|
||||||
|
private const EXCLUDED_KEYWORDS = [
|
||||||
|
// National orders and decorations
|
||||||
|
'chevalier', 'officier', 'commandeur', 'compagnon',
|
||||||
|
'ordre du', 'ordre de', 'order of the',
|
||||||
|
'grand-croix', 'grand officier', 'grand cordon',
|
||||||
|
'Knight Bachelor', 'Knight Commander',
|
||||||
|
'croix d\'',
|
||||||
|
// Honorary degrees and memberships
|
||||||
|
'honoris causa',
|
||||||
|
'membre de l\'', 'membre de la', 'membre honoraire', 'membre associé', 'membre élu',
|
||||||
|
'Fellow of', 'fellow de',
|
||||||
|
// Scholarships
|
||||||
|
'bourse ',
|
||||||
|
// Medals (military, scientific, etc.)
|
||||||
|
'médaille', 'Medal',
|
||||||
|
// Other non-entertainment
|
||||||
|
'Time 100', '100 Women', 'All-NBA',
|
||||||
|
'étoile du Hollywood Walk of Fame',
|
||||||
|
'allée des célébrités',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private WikidataGateway $wikidataGateway,
|
||||||
|
private AwardTypeRepository $awardTypeRepository,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private ?LoggerInterface $logger = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function importForActor(Actor $actor): void
|
||||||
|
{
|
||||||
|
$this->importForActors([$actor]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Actor> $actors
|
||||||
|
*/
|
||||||
|
public function importForActors(array $actors): void
|
||||||
|
{
|
||||||
|
$actorsToFetch = array_filter($actors, fn (Actor $a) => !$a->isAwardsImported());
|
||||||
|
|
||||||
|
if ([] === $actorsToFetch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$allAwards = $this->wikidataGateway->getAwardsForActors(array_values($actorsToFetch));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger?->warning('Failed to fetch awards from Wikidata', [
|
||||||
|
'actors' => array_map(fn (Actor $a) => $a->getName(), $actorsToFetch),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$knownTypes = $this->awardTypeRepository->findAll();
|
||||||
|
|
||||||
|
foreach ($actorsToFetch as $actor) {
|
||||||
|
$wikidataAwards = $allAwards[$actor->getName()] ?? [];
|
||||||
|
|
||||||
|
foreach ($wikidataAwards as $wikidataAward) {
|
||||||
|
if ($this->isExcluded($wikidataAward['name'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes);
|
||||||
|
|
||||||
|
$award = new Award();
|
||||||
|
$award->setName($wikidataAward['name']);
|
||||||
|
$award->setYear($wikidataAward['year']);
|
||||||
|
$award->setActor($actor);
|
||||||
|
$award->setAwardType($awardType);
|
||||||
|
|
||||||
|
$this->em->persist($award);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor->setAwardsImported(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AwardType> $knownTypes
|
||||||
|
*/
|
||||||
|
private function resolveAwardType(string $awardName, array &$knownTypes): AwardType
|
||||||
|
{
|
||||||
|
// 1. Try canonical map first
|
||||||
|
$canonicalName = $this->findCanonicalName($awardName);
|
||||||
|
|
||||||
|
if (null !== $canonicalName) {
|
||||||
|
foreach ($knownTypes as $type) {
|
||||||
|
if ($type->getName() === $canonicalName) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newType = new AwardType();
|
||||||
|
$newType->setName($canonicalName);
|
||||||
|
$newType->setPattern($canonicalName);
|
||||||
|
$this->em->persist($newType);
|
||||||
|
$knownTypes[] = $newType;
|
||||||
|
|
||||||
|
return $newType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to existing pattern matching
|
||||||
|
foreach ($knownTypes as $type) {
|
||||||
|
if (str_contains($awardName, $type->getPattern())) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create new type with prefix extraction
|
||||||
|
$newType = new AwardType();
|
||||||
|
$prefix = $this->extractPrefix($awardName);
|
||||||
|
$newType->setName($prefix);
|
||||||
|
$newType->setPattern($prefix);
|
||||||
|
|
||||||
|
$this->em->persist($newType);
|
||||||
|
$knownTypes[] = $newType;
|
||||||
|
|
||||||
|
return $newType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findCanonicalName(string $awardName): ?string
|
||||||
|
{
|
||||||
|
$normalized = mb_strtolower($awardName);
|
||||||
|
|
||||||
|
foreach (self::AWARD_MAP as $canonical => $keywords) {
|
||||||
|
foreach ($keywords as $keyword) {
|
||||||
|
if (str_contains($normalized, mb_strtolower($keyword))) {
|
||||||
|
return $canonical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isExcluded(string $awardName): bool
|
||||||
|
{
|
||||||
|
$normalized = mb_strtolower($awardName);
|
||||||
|
|
||||||
|
foreach (self::EXCLUDED_KEYWORDS as $keyword) {
|
||||||
|
if (str_contains($normalized, mb_strtolower($keyword))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractPrefix(string $awardName): string
|
||||||
|
{
|
||||||
|
// "X for Y", "X pour Y", "X du Y", "X de la Y", "X de l'Y", "X des Y"
|
||||||
|
if (preg_match('/^(.+?)\s+(?:for|pour|du|de la|de l\'|des)\s+/iu', $awardName, $matches)) {
|
||||||
|
return trim($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "... festival de cinéma de X" or "... festival de X"
|
||||||
|
if (preg_match('/festival\s+(?:de\s+(?:cinéma\s+de\s+)?)?(.+?)$/iu', $awardName, $matches)) {
|
||||||
|
return trim($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $awardName;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/Import/FilmImporter.php
Normal file
48
src/Import/FilmImporter.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use App\Entity\Movie;
|
||||||
|
use App\Exception\GatewayException;
|
||||||
|
use App\Gateway\TMDBGateway;
|
||||||
|
use App\Model\Ltbxd\LtbxdMovie;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
readonly class FilmImporter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TMDBGateway $tmdbGateway,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing Movie by ltbxdRef or create a new one via TMDB.
|
||||||
|
* Returns null if the movie is not found on TMDB.
|
||||||
|
*
|
||||||
|
* @throws GatewayException
|
||||||
|
*/
|
||||||
|
public function importFromLtbxdMovie(LtbxdMovie $ltbxdMovie): ?Movie
|
||||||
|
{
|
||||||
|
$existing = $this->em->getRepository(Movie::class)->findOneBy(['ltbxdRef' => $ltbxdMovie->getLtbxdRef()]);
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmdbMovie = $this->tmdbGateway->searchMovie($ltbxdMovie->getName());
|
||||||
|
if (!$tmdbMovie) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$movie = new Movie()
|
||||||
|
->setLtbxdRef($ltbxdMovie->getLtbxdRef())
|
||||||
|
->setTitle($ltbxdMovie->getName())
|
||||||
|
->setTmdbId($tmdbMovie->getId())
|
||||||
|
->setYear($ltbxdMovie->getYear());
|
||||||
|
|
||||||
|
$this->em->persist($movie);
|
||||||
|
|
||||||
|
return $movie;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Message/ImportFilmsBatchMessage.php
Normal file
16
src/Message/ImportFilmsBatchMessage.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
readonly class ImportFilmsBatchMessage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{name: string, year: int, ltbxdUri: string, date: string}> $films
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $importId,
|
||||||
|
public array $films,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
12
src/Message/ProcessImportMessage.php
Normal file
12
src/Message/ProcessImportMessage.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
readonly class ProcessImportMessage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $importId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
98
src/MessageHandler/ImportFilmsBatchMessageHandler.php
Normal file
98
src/MessageHandler/ImportFilmsBatchMessageHandler.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\MessageHandler;
|
||||||
|
|
||||||
|
use App\Entity\Import;
|
||||||
|
use App\Entity\UserMovie;
|
||||||
|
use App\Message\ImportFilmsBatchMessage;
|
||||||
|
use App\Model\Ltbxd\LtbxdMovie;
|
||||||
|
use App\Repository\ImportRepository;
|
||||||
|
use App\Import\ActorSyncer;
|
||||||
|
use App\Import\AwardImporter;
|
||||||
|
use App\Import\FilmImporter;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
readonly class ImportFilmsBatchMessageHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private FilmImporter $filmImporter,
|
||||||
|
private ActorSyncer $actorSyncer,
|
||||||
|
private ImportRepository $importRepository,
|
||||||
|
private AwardImporter $awardImporter,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(ImportFilmsBatchMessage $message): void
|
||||||
|
{
|
||||||
|
$import = $this->em->getRepository(Import::class)->find($message->importId);
|
||||||
|
if (!$import) {
|
||||||
|
$this->logger->error('Import not found', ['importId' => $message->importId]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch = array_map(
|
||||||
|
fn (array $film) => new LtbxdMovie(
|
||||||
|
date: new \DateTime($film['date']),
|
||||||
|
name: $film['name'],
|
||||||
|
year: $film['year'],
|
||||||
|
ltbxdUri: $film['ltbxdUri'],
|
||||||
|
),
|
||||||
|
$message->films,
|
||||||
|
);
|
||||||
|
$userId = $import->getUser()->getId();
|
||||||
|
$importId = $import->getId();
|
||||||
|
|
||||||
|
foreach ($batch as $ltbxdMovie) {
|
||||||
|
try {
|
||||||
|
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
|
||||||
|
if (!$movie) {
|
||||||
|
$this->importRepository->incrementFailedFilms($import);
|
||||||
|
} else {
|
||||||
|
$this->actorSyncer->syncActorsForMovie($movie);
|
||||||
|
|
||||||
|
$actors = array_map(fn ($role) => $role->getActor(), $movie->getActors()->toArray());
|
||||||
|
$this->awardImporter->importForActors($actors);
|
||||||
|
|
||||||
|
$user = $this->em->getReference(\App\Entity\User::class, $userId);
|
||||||
|
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
|
||||||
|
'user' => $user,
|
||||||
|
'movie' => $movie,
|
||||||
|
]);
|
||||||
|
if (!$existingLink) {
|
||||||
|
$userMovie = new UserMovie();
|
||||||
|
$userMovie->setUser($user);
|
||||||
|
$userMovie->setMovie($movie);
|
||||||
|
$this->em->persist($userMovie);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('Failed to import film', [
|
||||||
|
'film' => $ltbxdMovie->getName(),
|
||||||
|
'importId' => $importId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$this->importRepository->incrementFailedFilms($import);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processedFilms = $this->importRepository->incrementProcessedFilms($import);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
$import = $this->em->getRepository(Import::class)->find($importId);
|
||||||
|
|
||||||
|
if ($processedFilms >= $import->getTotalFilms()) {
|
||||||
|
$import->setStatus(Import::STATUS_COMPLETED);
|
||||||
|
$import->setCompletedAt(new \DateTimeImmutable());
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/MessageHandler/ProcessImportMessageHandler.php
Normal file
81
src/MessageHandler/ProcessImportMessageHandler.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\MessageHandler;
|
||||||
|
|
||||||
|
use App\Entity\Import;
|
||||||
|
use App\Gateway\LtbxdGateway;
|
||||||
|
use App\Message\ImportFilmsBatchMessage;
|
||||||
|
use App\Message\ProcessImportMessage;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
readonly class ProcessImportMessageHandler
|
||||||
|
{
|
||||||
|
private const int BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private FilesystemOperator $defaultStorage,
|
||||||
|
private LtbxdGateway $ltbxdGateway,
|
||||||
|
private MessageBusInterface $bus,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(ProcessImportMessage $message): void
|
||||||
|
{
|
||||||
|
$import = $this->em->getRepository(Import::class)->find($message->importId);
|
||||||
|
if (!$import) {
|
||||||
|
$this->logger->error('Import not found', ['importId' => $message->importId]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$csvContent = $this->defaultStorage->read($import->getFilePath());
|
||||||
|
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'import_');
|
||||||
|
file_put_contents($tmpFile, $csvContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile);
|
||||||
|
} finally {
|
||||||
|
unlink($tmpFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalFilms = count($ltbxdMovies);
|
||||||
|
|
||||||
|
$import->setTotalFilms($totalFilms);
|
||||||
|
$import->setStatus(Import::STATUS_PROCESSING);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$batches = array_chunk($ltbxdMovies, self::BATCH_SIZE);
|
||||||
|
foreach ($batches as $batch) {
|
||||||
|
$films = array_map(fn ($movie) => [
|
||||||
|
'name' => $movie->getName(),
|
||||||
|
'year' => $movie->getYear(),
|
||||||
|
'ltbxdUri' => $movie->getLtbxdUri(),
|
||||||
|
'date' => $movie->getDate()->format('Y-m-d'),
|
||||||
|
], $batch);
|
||||||
|
|
||||||
|
$this->bus->dispatch(new ImportFilmsBatchMessage(
|
||||||
|
importId: $import->getId(),
|
||||||
|
films: $films,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('Import processing failed', [
|
||||||
|
'importId' => $import->getId(),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import->setStatus(Import::STATUS_FAILED);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/Provider/GameGridProvider.php
Normal file
279
src/Provider/GameGridProvider.php
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Provider;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\ActorRepository;
|
||||||
|
use App\Repository\MovieRepository;
|
||||||
|
use App\Repository\MovieRoleRepository;
|
||||||
|
use App\Repository\AwardRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
class GameGridProvider
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly MovieRoleRepository $movieRoleRepository,
|
||||||
|
private readonly MovieRepository $movieRepository,
|
||||||
|
private readonly AwardRepository $awardRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{watchedOnly?: bool, hintTypes?: list<string>, awardTypeIds?: list<int>|null} $config
|
||||||
|
*/
|
||||||
|
public function generate(?User $user = null, array $config = []): ?Game
|
||||||
|
{
|
||||||
|
$watchedOnly = $config['watchedOnly'] ?? false;
|
||||||
|
$hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award'];
|
||||||
|
$awardTypeIds = $config['awardTypeIds'] ?? null;
|
||||||
|
|
||||||
|
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||||
|
$game = $this->tryGenerate($user, $watchedOnly, $hintTypes, $awardTypeIds);
|
||||||
|
if ($game !== null) {
|
||||||
|
return $game;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $hintTypes
|
||||||
|
* @param list<int>|null $awardTypeIds
|
||||||
|
*/
|
||||||
|
private function tryGenerate(?User $user, bool $watchedOnly, array $hintTypes, ?array $awardTypeIds): ?Game
|
||||||
|
{
|
||||||
|
if ($watchedOnly && $user !== null) {
|
||||||
|
$mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4);
|
||||||
|
} else {
|
||||||
|
$mainActor = $this->actorRepository->findOneRandom(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mainActor === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = new Game();
|
||||||
|
$game->setMainActor($mainActor);
|
||||||
|
$game->setUser($user);
|
||||||
|
|
||||||
|
$usedActors = [$mainActor->getId()];
|
||||||
|
$rowOrder = 0;
|
||||||
|
|
||||||
|
foreach (str_split(strtolower($mainActor->getName())) as $char) {
|
||||||
|
if (!preg_match('/[a-z]/', $char)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = null;
|
||||||
|
for ($try = 0; $try < 5; $try++) {
|
||||||
|
if ($watchedOnly && $user !== null) {
|
||||||
|
$candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char);
|
||||||
|
} else {
|
||||||
|
$candidate = $this->actorRepository->findOneRandom(4, $char);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {
|
||||||
|
$actor = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actor === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$usedActors[] = $actor->getId();
|
||||||
|
|
||||||
|
$row = new GameRow();
|
||||||
|
$row->setActor($actor);
|
||||||
|
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
||||||
|
$row->setRowOrder($rowOrder);
|
||||||
|
|
||||||
|
$hint = $this->generateHint($actor, $hintTypes, $awardTypeIds);
|
||||||
|
if ($hint === null) {
|
||||||
|
return null; // Every row must have a hint
|
||||||
|
}
|
||||||
|
$row->setHintType($hint['type']);
|
||||||
|
$row->setHintData($hint['data']);
|
||||||
|
|
||||||
|
$game->addRow($row);
|
||||||
|
++$rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($game);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute display data (grid, width, middle) from a Game entity for the React component.
|
||||||
|
*
|
||||||
|
* @return array{grid: list<array{actorName: string, actorId: int, pos: int, hintType: ?string, hintText: ?string}>, width: int, middle: int}
|
||||||
|
*/
|
||||||
|
public function computeGridData(Game $game): array
|
||||||
|
{
|
||||||
|
$leftSize = 0;
|
||||||
|
$rightSize = 0;
|
||||||
|
$grid = [];
|
||||||
|
|
||||||
|
$mainActorChars = str_split($game->getMainActor()->getName());
|
||||||
|
$rows = $game->getRows()->toArray();
|
||||||
|
$rowIndex = 0;
|
||||||
|
|
||||||
|
foreach ($mainActorChars as $char) {
|
||||||
|
if (!preg_match('/[a-zA-Z]/', $char)) {
|
||||||
|
$grid[] = [
|
||||||
|
'separator' => $char,
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $rows[$rowIndex] ?? null;
|
||||||
|
++$rowIndex;
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = $row->getActor();
|
||||||
|
$pos = $row->getPosition();
|
||||||
|
|
||||||
|
if ($leftSize < $pos) {
|
||||||
|
$leftSize = $pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rightSizeActor = strlen($actor->getName()) - $pos - 1;
|
||||||
|
if ($rightSize < $rightSizeActor) {
|
||||||
|
$rightSize = $rightSizeActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hintText = $this->resolveHintText($row);
|
||||||
|
|
||||||
|
$grid[] = [
|
||||||
|
'actorName' => $actor->getName(),
|
||||||
|
'actorId' => $actor->getId(),
|
||||||
|
'pos' => $pos,
|
||||||
|
'hintType' => $row->getHintType(),
|
||||||
|
'hintText' => $hintText,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'grid' => $grid,
|
||||||
|
'width' => $rightSize + $leftSize + 1,
|
||||||
|
'middle' => $leftSize,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $allowedTypes
|
||||||
|
* @param list<int>|null $awardTypeIds
|
||||||
|
* @return array{type: string, data: string}|null
|
||||||
|
*/
|
||||||
|
private function generateHint(Actor $rowActor, array $allowedTypes = ['film', 'character', 'award'], ?array $awardTypeIds = null): ?array
|
||||||
|
{
|
||||||
|
$types = $allowedTypes;
|
||||||
|
shuffle($types);
|
||||||
|
|
||||||
|
foreach ($types as $type) {
|
||||||
|
$hint = $this->resolveHint($type, $rowActor, $awardTypeIds);
|
||||||
|
if ($hint !== null) {
|
||||||
|
return $hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int>|null $awardTypeIds
|
||||||
|
* @return array{type: string, data: string}|null
|
||||||
|
*/
|
||||||
|
private function resolveHint(string $type, Actor $rowActor, ?array $awardTypeIds = null): ?array
|
||||||
|
{
|
||||||
|
$actorId = $rowActor->getId();
|
||||||
|
if ($actorId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'film':
|
||||||
|
$role = $this->movieRoleRepository->findOneRandomByActor($actorId);
|
||||||
|
if ($role === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()];
|
||||||
|
|
||||||
|
case 'character':
|
||||||
|
$role = $this->movieRoleRepository->findOneRandomByActor($actorId);
|
||||||
|
if ($role === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['type' => 'character', 'data' => (string) $role->getId()];
|
||||||
|
|
||||||
|
case 'award':
|
||||||
|
$award = $this->awardRepository->findOneRandomByActorAndTypes($actorId, $awardTypeIds);
|
||||||
|
if ($award === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['type' => 'award', 'data' => (string) $award->getId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveHintText(GameRow $row): ?string
|
||||||
|
{
|
||||||
|
$type = $row->getHintType();
|
||||||
|
$data = $row->getHintData();
|
||||||
|
|
||||||
|
if ($type === null || $data === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'film' => $this->resolveFilmHintText((int) $data),
|
||||||
|
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
|
||||||
|
'award' => $this->resolveAwardHintText((int) $data),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFilmHintText(int $movieId): ?string
|
||||||
|
{
|
||||||
|
$movie = $this->movieRepository->find($movieId);
|
||||||
|
if ($movie === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $movie->getTitle();
|
||||||
|
if ($movie->getYear() !== null) {
|
||||||
|
$title .= ' (' . $movie->getYear() . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAwardHintText(int $awardId): ?string
|
||||||
|
{
|
||||||
|
$award = $this->awardRepository->find($awardId);
|
||||||
|
if ($award === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $award->getName();
|
||||||
|
if ($award->getYear() !== null) {
|
||||||
|
$text .= ' (' . $award->getYear() . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\UserMovie;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
@@ -38,4 +40,29 @@ class ActorRepository extends ServiceEntityRepository
|
|||||||
->getOneOrNullResult()
|
->getOneOrNullResult()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findOneRandomInWatchedFilms(User $user, ?float $popularity = null, ?string $char = null): ?Actor
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('a')
|
||||||
|
->join('a.movieRoles', 'mr')
|
||||||
|
->join('mr.movie', 'm')
|
||||||
|
->join(UserMovie::class, 'um', 'WITH', 'um.movie = m AND um.user = :user')
|
||||||
|
->setParameter('user', $user);
|
||||||
|
|
||||||
|
if (!empty($popularity)) {
|
||||||
|
$qb->andWhere('a.popularity >= :popularity')
|
||||||
|
->setParameter('popularity', $popularity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($char)) {
|
||||||
|
$qb->andWhere('LOWER(a.name) LIKE LOWER(:name)')
|
||||||
|
->setParameter('name', '%' . $char . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb
|
||||||
|
->orderBy('RANDOM()')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/Repository/AwardRepository.php
Normal file
54
src/Repository/AwardRepository.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Award;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/** @extends ServiceEntityRepository<Award> */
|
||||||
|
class AwardRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Award::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneRandomByActor(int $actorId): ?Award
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('a')
|
||||||
|
->andWhere('a.actor = :actorId')
|
||||||
|
->setParameter('actorId', $actorId)
|
||||||
|
->orderBy('RANDOM()')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int>|null $awardTypeIds null means all types
|
||||||
|
*/
|
||||||
|
public function findOneRandomByActorAndTypes(int $actorId, ?array $awardTypeIds): ?Award
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('a')
|
||||||
|
->andWhere('a.actor = :actorId')
|
||||||
|
->setParameter('actorId', $actorId);
|
||||||
|
|
||||||
|
if ($awardTypeIds !== null && empty($awardTypeIds)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($awardTypeIds !== null) {
|
||||||
|
$qb->andWhere('a.awardType IN (:typeIds)')
|
||||||
|
->setParameter('typeIds', $awardTypeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb
|
||||||
|
->orderBy('RANDOM()')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Repository/AwardTypeRepository.php
Normal file
37
src/Repository/AwardTypeRepository.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/** @extends ServiceEntityRepository<AwardType> */
|
||||||
|
class AwardTypeRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, AwardType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<AwardType> */
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
return parent::findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<AwardType> */
|
||||||
|
public function findWithMinActors(int $minActors): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('at')
|
||||||
|
->join('at.awards', 'a')
|
||||||
|
->groupBy('at.id')
|
||||||
|
->having('COUNT(DISTINCT a.actor) >= :minActors')
|
||||||
|
->setParameter('minActors', $minActors)
|
||||||
|
->orderBy('COUNT(DISTINCT a.actor)', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Repository/GameRepository.php
Normal file
33
src/Repository/GameRepository.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Game>
|
||||||
|
*/
|
||||||
|
class GameRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Game::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findActiveForUser(User $user): ?Game
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('g')
|
||||||
|
->andWhere('g.user = :user')
|
||||||
|
->andWhere('g.status = :status')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->setParameter('status', Game::STATUS_IN_PROGRESS)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Repository/GameRowRepository.php
Normal file
20
src/Repository/GameRowRepository.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<GameRow>
|
||||||
|
*/
|
||||||
|
class GameRowRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, GameRow::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Repository/ImportRepository.php
Normal file
59
src/Repository/ImportRepository.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Import;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Import>
|
||||||
|
*/
|
||||||
|
class ImportRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Import::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function incrementProcessedFilms(Import $import): int
|
||||||
|
{
|
||||||
|
return (int) $this->getEntityManager()->getConnection()->fetchOne(
|
||||||
|
'UPDATE import SET processed_films = processed_films + 1 WHERE id = :id RETURNING processed_films',
|
||||||
|
['id' => $import->getId()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function incrementFailedFilms(Import $import): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->getConnection()->executeStatement(
|
||||||
|
'UPDATE import SET failed_films = failed_films + 1 WHERE id = :id',
|
||||||
|
['id' => $import->getId()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findLatestForUser(\App\Entity\User $user): ?Import
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('i')
|
||||||
|
->andWhere('i.user = :user')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->orderBy('i.createdAt', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasActiveImport(\App\Entity\User $user): bool
|
||||||
|
{
|
||||||
|
return (int) $this->createQueryBuilder('i')
|
||||||
|
->select('COUNT(i.id)')
|
||||||
|
->andWhere('i.user = :user')
|
||||||
|
->andWhere('i.status IN (:statuses)')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->setParameter('statuses', [Import::STATUS_PENDING, Import::STATUS_PROCESSING])
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,28 +16,25 @@ class MovieRoleRepository extends ServiceEntityRepository
|
|||||||
parent::__construct($registry, MovieRole::class);
|
parent::__construct($registry, MovieRole::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
// /**
|
/**
|
||||||
// * @return MovieRole[] Returns an array of MovieRole objects
|
* @param list<int> $excludeMovieRoleIds MovieRole IDs to exclude
|
||||||
// */
|
* @return MovieRole|null
|
||||||
// public function findByExampleField($value): array
|
*/
|
||||||
// {
|
public function findOneRandomByActor(int $actorId, array $excludeMovieRoleIds = []): ?MovieRole
|
||||||
// return $this->createQueryBuilder('m')
|
{
|
||||||
// ->andWhere('m.exampleField = :val')
|
$qb = $this->createQueryBuilder('mr')
|
||||||
// ->setParameter('val', $value)
|
->andWhere('mr.actor = :actorId')
|
||||||
// ->orderBy('m.id', 'ASC')
|
->setParameter('actorId', $actorId);
|
||||||
// ->setMaxResults(10)
|
|
||||||
// ->getQuery()
|
|
||||||
// ->getResult()
|
|
||||||
// ;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// public function findOneBySomeField($value): ?MovieRole
|
if (!empty($excludeMovieRoleIds)) {
|
||||||
// {
|
$qb->andWhere('mr.id NOT IN (:excludeIds)')
|
||||||
// return $this->createQueryBuilder('m')
|
->setParameter('excludeIds', $excludeMovieRoleIds);
|
||||||
// ->andWhere('m.exampleField = :val')
|
}
|
||||||
// ->setParameter('val', $value)
|
|
||||||
// ->getQuery()
|
return $qb
|
||||||
// ->getOneOrNullResult()
|
->orderBy('RANDOM()')
|
||||||
// ;
|
->setMaxResults(1)
|
||||||
// }
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user