Compare commits

..

86 Commits

Author SHA1 Message Date
Maxime BATISTA b941d6530c Update '.env'
continuous-integration/drone/push Build is passing Details
1 year ago
maxime.batista 1faa8168f9 fix cards
continuous-integration/drone/push Build is passing Details
1 year ago
Maxime BATISTA 0de42db300 Merge pull request 'Export, Import, Remove, Duplicate and preview tactics' (#120) from home/tactic-management into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 262cf97445 Apply suggestions
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 47d81bb665 add tactic import and duplication in home page
continuous-integration/drone/push Build is passing Details
1 year ago
maxime eef1e16830 add JSON export
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 98eed72af6 add possibility to delete a tactic from the home page
1 year ago
maxime 7289a956b3 add tactic preview in home
1 year ago
Maxime BATISTA 3da28f828d Merge pull request 'Add a visualizer' (#119) from visualizer into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime f9e436ea12 Apply suggestion
continuous-integration/drone/push Build is passing Details
1 year ago
maxime cab6fc43ca fix steps in visualizer
1 year ago
maxime 3091e1a61a add back to edition button in visualizer if tactic is editable
1 year ago
maxime 15b47f354e add visualizer
1 year ago
Maxime BATISTA 9e8606184c Merge pull request 'Add settings page, refactor session management' (#118) from settings-reborn into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime f9c42862e0 apply suggestions
continuous-integration/drone/push Build is passing Details
1 year ago
maxime d6f1a633a1 remove profile picture link verification
1 year ago
maxime 42c0300ced remove bottstrap
1 year ago
maxime fa7339b0f1 add a keep alive session loop
1 year ago
maxime 6738ddcb67 add settings page, refactor session handling
1 year ago
maxime 62be8f2a0b fix guest mode
1 year ago
maxime 3a983f593a fix guest mode
continuous-integration/drone/push Build is passing Details
1 year ago
Maxime BATISTA 307a978e15 Merge pull request 'Replicate the parent's content to the visualisation of a step.' (#117) from replicated-parent into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime bef196e09e Apply suggestions and format
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 882e45f328 fix steps bugs
1 year ago
maxime 69edb10b04 fix player with or without ball to the same size
1 year ago
maxime 80c94d733f add parent's content view in children
1 year ago
maxime c544f88de7 fix undo-redo
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 0eba18784e use new API's date standard
continuous-integration/drone/push Build is passing Details
1 year ago
Maxime BATISTA 4ea531c08c Merge pull request 'add simple undo/redo' (#116) from undo-redo into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 102bc774af add simple undo/redo
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 415350887d do not paralellize requests
continuous-integration/drone/push Build is passing Details
1 year ago
Maxime BATISTA cca7ee1b1b Merge pull request 'Add steps to the editor' (#114) from editor/steps into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime b87db24e9e Apply suggestions
continuous-integration/drone/push Build is passing Details
1 year ago
maxime f7e2b9216d keep initial route in memory if redirected to login page from /tactic/new
continuous-integration/drone/push Build is failing Details
1 year ago
maxime.batista df10eba8d2 the tree is now shown with a resizeable curtain-slide view
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 4fe1ddfbd2 fix step tree and step update
continuous-integration/drone/push Build is passing Details
1 year ago
maxime fcd0a94535 do not paralellise steps updates
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 64e8362e53 format
1 year ago
maxime 32b79ed5c4 fix guest mode editor, avoid some illegal actions
1 year ago
maxime 2577974bfe drain changes on all children
1 year ago
maxime f5b7b61411 add read only court players
1 year ago
maxime.batista 72273e3f3e spread changes of step content to its direct children
1 year ago
maxime 4cee7649af compute the terminal state of a step and apply it on newly created children
1 year ago
maxime 1d235da809 fixed step remove on backend side, adding a step now duplicates the content of the parent
1 year ago
maxime 6b4e5ba36b support steps in guest mode, EditorView refactored
1 year ago
maxime 65672c404a fix ci
1 year ago
maxime 6756e4064e fix routes
1 year ago
maxime d26edd791a steps can now contain individual content on editor side
1 year ago
maxime 034afc3649 add a steps visualizer (fake data)
1 year ago
maxime ed6c62217a fix register page
continuous-integration/drone/push Build is passing Details
1 year ago
Maxime BATISTA fd9b5e2063 Merge pull request 'Add screen phantoms' (#113) from editor/screen-phantoms into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime.batista 2a5de88380 Apply suggestions
continuous-integration/drone/push Build is passing Details
1 year ago
maxime.batista 1043207e2d apply suggestions
continuous-integration/drone/push Build is passing Details
1 year ago
maxime.batista 5359bb12de format
continuous-integration/drone/push Build is passing Details
1 year ago
maxime.batista 5963290f67 fix bug when a player got the ball while doing a screen to another player
1 year ago
maxime.batista b6f2a97d8f fix crash when removing a player that was attached, or a screen action
1 year ago
maxime.batista 8b407b67eb fix crash when removing a player that had phantoms attached to it
1 year ago
maxime c3cf23da0c add phantoms for screen actions
1 year ago
maxime.batista 264b46cd02 remove -x option in deploy.sh/set
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 9835dbdcbf apply suggestions
1 year ago
maxime a52c20920f fix redirections
1 year ago
maxime 8d99066cf4 apply suggestions
1 year ago
maxime 0a5051ceaa apply suggestions
1 year ago
maxime 51abcd6d5d fix ci
1 year ago
maxime 45b5782678 remove debug logs and format
1 year ago
maxime 784beab810 apply suggestions
1 year ago
maxime 48558e66de configure eslint
1 year ago
maxime 7a614325f0 fix header blinking
1 year ago
maxime 0312cb3645 fix tactic edition, bring back guest mode
1 year ago
maxime de698a58f5 bring back support of opening a specific tactic
1 year ago
maxime 628b9348b3 add token renewal
1 year ago
maxime 5efeb52311 add iqball header on all pages
1 year ago
maxime 6e5cdeca96 add jwt authentication
1 year ago
maxime 34c18e1b77 remove php, the front is now independant and uses react-router to serve the pages
1 year ago
maxime 3c2ce3415e fix user update
1 year ago
maxime 73fe9447de fix ci
1 year ago
maxime 6931bfa8a5 keep fields values on login/register fail
1 year ago
maxime 0d0f6138a4 fix preflight requests
1 year ago
Maxime BATISTA 00da8d7f54 Merge pull request 'Add Phantoms' (#95) from editor/phantoms into master
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 1b435d4469 apply suggestions
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 9a6103e78a fix desynchronization when pass arrows are removed
continuous-integration/drone/push Build is passing Details
1 year ago
maxime 15c75ee269 fixes and format
1 year ago
maxime e97821a4fa stabilize phantoms, spread changes between players and phantoms if an action changes
1 year ago
maxime 85cd8b0383 store actions directly inside each components, enhance bendable arrows to hook to DOM elements
1 year ago
maxime.batista 852f163e4a add phantoms for move and dribble
1 year ago
maxime.batista 8d444c38b4 use one array of TacticComponent
1 year ago

@ -1,2 +1,2 @@
VITE_API_ENDPOINT=/api VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master
VITE_BASE= #VITE_API_ENDPOINT=http://localhost:5254

@ -0,0 +1,25 @@
module.exports = {
root: true,
env: { browser: true, es2021: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
settings: {
react: {
version: "detect",
},
},
}

@ -1,21 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'react',
'react-hooks'
],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended'
],
settings: {
react: {
version: 'detect'
}
}
};

60
.gitignore vendored

@ -1,46 +1,28 @@
.vs # Logs
.vscode logs
.idea *.log
.code npm-debug.log*
.vite yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
vendor node_modules
.nfs*
composer.lock
*.phar
dist dist
.guard dist-ssr
outputs *.local
# sqlite database files # Editor directories and files
*.sqlite .vscode/*
.idea
views-mappings.php .DS_Store
.env.PROD *.suo
*.ntvs*
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. *.njsproj
*.sln
*.sw?
# dependencies
/node_modules
/.pnp
.pnp.js
package-lock.json package-lock.json
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.php-cs-fixer.cache stats.html

@ -1,16 +0,0 @@
<?php
$finder = (new PhpCsFixer\Finder())->in(__DIR__);
return (new PhpCsFixer\Config())
->setRules([
'@PER-CS' => true,
'@PHP74Migration' => true,
'array_syntax' => ['syntax' => 'short'],
'braces_position' => [
'classes_opening_brace' => 'same_line',
'functions_opening_brace' => 'same_line'
]
])
->setIndent(" ")
->setFinder($finder);

@ -1,7 +1,7 @@
{ {
"bracketSameLine": true, "bracketSameLine": true,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 80, "printWidth": 80,
"tabWidth": 4, "tabWidth": 4,
"semi": false "semi": false
} }

@ -4,21 +4,23 @@
Notre projet est divisé en plusieurs parties: Notre projet est divisé en plusieurs parties:
- `src/API`, qui définit les classes qui implémentent les actions de lapi - `src/API`, qui définit les classes qui implémentent les actions de lapi
- `src/App`, qui définit les contrôleurs et les vues de lapplication web - `src/App`, qui définit les contrôleurs et les vues de lapplication web
- `src/Core`, définit les modèles, les classes métiers, les librairies internes (validation, http), les gateways, en somme, les élements logiques de lapplication et les actions que lont peut faire avec. - `src/Core`, définit les modèles, les classes métiers, les librairies internes (validation, http), les gateways, en somme, les élements logiques de lapplication et les actions que lont peut faire avec.
- `sql`, définit la base de donnée utilisée, et éxécute les fichiers sql lorsque la base de donnée nest pas initialisée. - `sql`, définit la base de donnée utilisée, et éxécute les fichiers sql lorsque la base de donnée nest pas initialisée.
- `profiles`, définit les profiles dexecution, voir `Documentation/how-to-dev.md` pour plus dinfo - `profiles`, définit les profiles dexecution, voir `Documentation/how-to-dev.md` pour plus dinfo
- `front` contient le code front-end react/typescript - `front` contient le code front-end react/typescript
- `ci` contient les scripts de déploiement et la définition du workflow dintégration continue et de déploiement constant vers notre staging server ([maxou.dev/<branch>/public/](https://maxou.dev/IQBall/master/public)). - `ci` contient les scripts de déploiement et la définition du workflow dintégration continue et de déploiement constant vers notre staging server ([maxou.dev/<branch>/public/](https://maxou.dev/IQBall/master/public)).
- `public` point dentrée, avec : - `public` point dentrée, avec :
- `public/index.php` point dentrée pour la webapp - `public/index.php` point dentrée pour la webapp
- `public/api/index.php` point dentrée pour lapi. - `public/api/index.php` point dentrée pour lapi.
## Backend ## Backend
### Validation et résilience des erreurs ### Validation et résilience des erreurs
#### Motivation #### Motivation
Un controlleur a pour but de valider les données d'une requête avant de les manipuler. Un controlleur a pour but de valider les données d'une requête avant de les manipuler.
Nous nous sommes rendu compte que la vérification des données d'une requête était redondante, même en factorisant les différents Nous nous sommes rendu compte que la vérification des données d'une requête était redondante, même en factorisant les différents
@ -56,6 +58,7 @@ Bien souvent, lorsque le prédicat échoue, un message est ajouté à la liste d
de la redondance sur les messages d'erreurs choisis lorsqu'une validation échoue. de la redondance sur les messages d'erreurs choisis lorsqu'une validation échoue.
#### Schéma #### Schéma
Toutes ces lignes de code pour définir que notre requête doit contenir un champ nommé `email`, dont la valeur est une adresse mail, d'une longueur comprise entre 6 et 64. Toutes ces lignes de code pour définir que notre requête doit contenir un champ nommé `email`, dont la valeur est une adresse mail, d'une longueur comprise entre 6 et 64.
Nous avons donc trouvé l'idée de définir un système nous permettant de déclarer le schéma d'une requête, Nous avons donc trouvé l'idée de définir un système nous permettant de déclarer le schéma d'une requête,
et de reporter le plus d'erreurs possibles lorsqu'une requête ne valide pas le schéma : et de reporter le plus d'erreurs possibles lorsqu'une requête ne valide pas le schéma :
@ -74,6 +77,7 @@ public function doPostAction(array $form): HttpResponse {
// traitement ... // traitement ...
} }
``` ```
Ce système nous permet de faire de la programmation _déclarative_, nous disons à _quoi_ ressemble la forme de la requête que l'on souhaite, Ce système nous permet de faire de la programmation _déclarative_, nous disons à _quoi_ ressemble la forme de la requête que l'on souhaite,
plustot que de définir _comment_ réagir face à notre requête. plustot que de définir _comment_ réagir face à notre requête.
Ce système permet d'être beaucoup plus clair sur la forme attendue d'une requête, et de garder une lisibilité satisfaisante peu importe le nombre de Ce système permet d'être beaucoup plus clair sur la forme attendue d'une requête, et de garder une lisibilité satisfaisante peu importe le nombre de
@ -84,6 +88,7 @@ plus de précision sur une erreur, comme le nom du champ qui est invalidé, et q
les erreurs et facilement entourer les champs invalides en rouge, ainsi que d'afficher toutes les erreurs que l'utilisateur a fait, d'un coup. les erreurs et facilement entourer les champs invalides en rouge, ainsi que d'afficher toutes les erreurs que l'utilisateur a fait, d'un coup.
### HttpRequest, HttpResponse ### HttpRequest, HttpResponse
Nous avons choisi d'encapsuler les requêtes et les réponses afin de faciliter leur manipulation. Nous avons choisi d'encapsuler les requêtes et les réponses afin de faciliter leur manipulation.
Cela permet de mieux repérer les endroits du code qui manipulent une requête d'un simple tableau, Cela permet de mieux repérer les endroits du code qui manipulent une requête d'un simple tableau,
et de garder à un seul endroit la responsabilitée d'écrire le contenu de la requête vers client. et de garder à un seul endroit la responsabilitée d'écrire le contenu de la requête vers client.
@ -92,6 +97,7 @@ et de garder à un seul endroit la responsabilitée d'écrire le contenu de la r
C'est ensuite à la classe `src/App/App` d'afficher la réponse. C'est ensuite à la classe `src/App/App` d'afficher la réponse.
### index.php ### index.php
Il y a deux points d'entrés, celui de la WebApp (`public/index.php`), et celui de l'API (`public/api/index.php`). Il y a deux points d'entrés, celui de la WebApp (`public/index.php`), et celui de l'API (`public/api/index.php`).
Ces fichiers ont pour but de définir ce qui va être utilisé dans l'application, comme les routes, la base de donnée, les classes métiers utilisés, Ces fichiers ont pour but de définir ce qui va être utilisé dans l'application, comme les routes, la base de donnée, les classes métiers utilisés,
comment gérer l'authentification dans le cadre de l'API, et une session dans le cadre de la web app. comment gérer l'authentification dans le cadre de l'API, et une session dans le cadre de la web app.
@ -100,12 +106,12 @@ L'index définit aussi quoi faire lorsque l'application retourne une réponse. D
l'affichage dans une classe (`IQBall\App\App` et `IQBall\API\API`). l'affichage dans une classe (`IQBall\App\App` et `IQBall\API\API`).
### API ### API
Nous avons définit une petite API (`src/Api`) pour nous permettre de faire des actions en arrière plan depuis le front end. Nous avons définit une petite API (`src/Api`) pour nous permettre de faire des actions en arrière plan depuis le front end.
Par exemple, l'API permet de changer le nom d'une tactique, ou de sauvegarder son contenu. Par exemple, l'API permet de changer le nom d'une tactique, ou de sauvegarder son contenu.
C'est plus pratique de faire des requêtes en arrière-plan plustot que faire une requête directement à la partie WebApp, ce qui C'est plus pratique de faire des requêtes en arrière-plan plustot que faire une requête directement à la partie WebApp, ce qui
aurait eu pour conséquences de recharger la page aurait eu pour conséquences de recharger la page
## Frontend ## Frontend
### Utilisation de React ### Utilisation de React
@ -120,4 +126,3 @@ il faut que les flèches qui y sont liés bougent aussi, il faut que les joueurs
Le front-end de léditeur et du visualiseur étant assez ambitieux, et occupant une place importante du projet, nous avons décidés de leffectuer en utilisant Le front-end de léditeur et du visualiseur étant assez ambitieux, et occupant une place importante du projet, nous avons décidés de leffectuer en utilisant
le framework React qui rend simple le développement dinterfaces dynamiques, et dutiliser typescript parce quici on code bien et quon impose une type safety a notre code. le framework React qui rend simple le développement dinterfaces dynamiques, et dutiliser typescript parce quici on code bien et quon impose une type safety a notre code.

@ -1,6 +1,7 @@
# Welcome on the documentation's description # Welcome on the documentation's description
## Let's get started with the architecture diagram. ## Let's get started with the architecture diagram.
![architecture diagram](./assets/architecture.svg) ![architecture diagram](./assets/architecture.svg)
As you can see our entire application is build around three main package. As you can see our entire application is build around three main package.
@ -13,7 +14,7 @@ Allowing to operate on it.
The App now is more about the web application itself. The App now is more about the web application itself.
Having all the controllers of the MVC architecture the use the model, the validation system and the http system in the core. Having all the controllers of the MVC architecture the use the model, the validation system and the http system in the core.
It also calls the twig's views inside of App. Finally, it uses the package Session. This one replace the $_SESSION we all know in PHP. It also calls the twig's views inside of App. Finally, it uses the package Session. This one replace the $\_SESSION we all know in PHP.
Thanks to this we have a way cleaner use of all session's data. Thanks to this we have a way cleaner use of all session's data.
Nevertheless, all the controllers call not only twig views but also react ones. Nevertheless, all the controllers call not only twig views but also react ones.
Those are present in the package "front", dispatched in several other packages. Those are present in the package "front", dispatched in several other packages.
@ -22,6 +23,7 @@ Such as assets having all the image and stuff, model containing all the data's s
Finally, we have the package "Api" that allows to share code and bind all the different third-hand application such as the web admin one. Finally, we have the package "Api" that allows to share code and bind all the different third-hand application such as the web admin one.
## Main data class diagram. ## Main data class diagram.
![Class diagram](./assets/models.svg) ![Class diagram](./assets/models.svg)
You can see how our data is structured contained in the package "data" as explained right above. You can see how our data is structured contained in the package "data" as explained right above.
@ -42,6 +44,7 @@ The last class we have is the Account. It could directly be incorporated in User
Then, Account only has a user and a token which is an identifier. Then, Account only has a user and a token which is an identifier.
## Validation's class diagram ## Validation's class diagram
![validation's class diagram](./assets/validation.svg) ![validation's class diagram](./assets/validation.svg)
We implemented our own validation system, here it is! We implemented our own validation system, here it is!
@ -54,6 +57,7 @@ The other part of the diagram is about the failure a specific field's validation
We have a concrete class to return a something more general. All the successors are just more precise about the failure. We have a concrete class to return a something more general. All the successors are just more precise about the failure.
## Http's class diagram ## Http's class diagram
![Http's class diagram](./assets/http.svg) ![Http's class diagram](./assets/http.svg)
It were we centralize what the app can render, and what the api can receive. It were we centralize what the app can render, and what the api can receive.
Then, we got the "basic" response (HttpResponse) that just render a HttpCodes. Then, we got the "basic" response (HttpResponse) that just render a HttpCodes.
@ -61,21 +65,26 @@ We have two successors for now. ViewHttpResponse render not only a code but also
Finally, we have the JsonHttpResponse that renders, as it's name says, some Json. Finally, we have the JsonHttpResponse that renders, as it's name says, some Json.
## Session's class diagram ## Session's class diagram
![Session's class diagram](./assets/session.svg) ![Session's class diagram](./assets/session.svg)
It encapsulates the PHP's array "$_SESSION". With two interfaces that dictate how a session should be handled, and same for a mutable one. It encapsulates the PHP's array "$\_SESSION". With two interfaces that dictate how a session should be handled, and same for a mutable one.
## Model View Controller ## Model View Controller
All class diagram, separated by their range of action, of the imposed MVC architecture. All class diagram, separated by their range of action, of the imposed MVC architecture.
All of them have a controller that validates entries with the validation system and check the permission the user has,and whether or not actually do the action. All of them have a controller that validates entries with the validation system and check the permission the user has,and whether or not actually do the action.
These controllers are composed by a Model that handle the pure data and is the point of contact between these and the gateways. These controllers are composed by a Model that handle the pure data and is the point of contact between these and the gateways.
Speaking of which, Gateways are composing Models. They use the connection class to access the database and send their query. Speaking of which, Gateways are composing Models. They use the connection class to access the database and send their query.
### Team ### Team
![team's mvc](./assets/team.svg) ![team's mvc](./assets/team.svg)
### Editor ### Editor
![editor's mvc](./assets/editor.svg) ![editor's mvc](./assets/editor.svg)
### Authentification ### Authentification
![auth's mvc](./assets/auth.svg)
![auth's mvc](./assets/auth.svg)

@ -1,3 +1,3 @@
* [Description.md](Description.md) - [Description.md](Description.md)
* [Conception.md](Conception.md) - [Conception.md](Conception.md)
* [how-to-dev.md](how-to-dev.md) - [how-to-dev.md](how-to-dev.md)

@ -3,6 +3,7 @@
object Account { object Account {
<u>id <u>id
name name
age
email email
phone_number phone_number
password_hash password_hash
@ -23,9 +24,6 @@ object Tactic {
<u>id <u>id
name name
creationDate creationDate
owner
content
courtType
} }
object Team { object Team {

@ -1,19 +1,21 @@
# THIS FILE IS DEPRECATED
See [#107](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/pulls/107) for more details
---
This documentation file explains how to start a development server on your This documentation file explains how to start a development server on your
machine, and how it works under the hood. machine, and how it works under the hood.
# How to run the project on my local computer # How to run the project on my local computer
1. Use phpstorm to run a local php server:
1) Use phpstorm to run a local php server: - Go to configuration > add new configuration
* Go to configuration > add new configuration - Select "PHP Built-in Web Server", then enter options as follow:
* Select "PHP Built-in Web Server", then enter options as follow: ![](assets/php-server-config.png) - port 8080 - name the configuration "RunServer" to be more explicit - place the "Document Root" in `/public` - host is localhost
![](assets/php-server-config.png) - Click apply, OK
- port 8080 - Now run it.
- name the configuration "RunServer" to be more explicit
- place the "Document Root" in `/public`
- host is localhost
* Click apply, OK
* Now run it.
If you go to `http://localhost:8080` you'll see a blank page. If you go to `http://localhost:8080` you'll see a blank page.
This is expected ! On your browser, open inspection tab (ctrl+shift+i) go to network and refresh the page. This is expected ! On your browser, open inspection tab (ctrl+shift+i) go to network and refresh the page.
@ -39,12 +41,14 @@ Now refresh your page, you should now see all request being fulfilled and a form
Caution: **NEVER** directly connect on the `localhost:5173` node development server, always pass through the php (`localhost:8080`) server. Caution: **NEVER** directly connect on the `localhost:5173` node development server, always pass through the php (`localhost:8080`) server.
# How it works # How it works
I'm glad you are interested in how that stuff works, it's a bit tricky, lets go. I'm glad you are interested in how that stuff works, it's a bit tricky, lets go.
If you look at our `index.php` (located in `/public` folder), you'll see that it define our routes, it uses an `AltoRouter` then delegates the request's action processing to a controller. If you look at our `index.php` (located in `/public` folder), you'll see that it define our routes, it uses an `AltoRouter` then delegates the request's action processing to a controller.
We can see that there are two registered routes, the `GET:/` (that then calls `SampleFormController#displayForm()`) and `POST:/result` (that calls `SampleFormController#displayResults()`). We can see that there are two registered routes, the `GET:/` (that then calls `SampleFormController#displayForm()`) and `POST:/result` (that calls `SampleFormController#displayResults()`).
Implementation of those two methods are very simple: there is no verification to make nor model to control, thus they directly sends the view back to the client. Implementation of those two methods are very simple: there is no verification to make nor model to control, thus they directly sends the view back to the client.
here's the implementation of the `SampleFormController` here's the implementation of the `SampleFormController`
```php ```php
require_once "react-display.php"; require_once "react-display.php";
class SampleFormController { class SampleFormController {
@ -63,7 +67,7 @@ As our views are now done using react (and defined under the `front/views` folde
If you look at the `send_react_front($viewURI, $viewArguments)` function, you'll see that is simply loads the file `src/react-display-file.php` with given arguments. If you look at the `send_react_front($viewURI, $viewArguments)` function, you'll see that is simply loads the file `src/react-display-file.php` with given arguments.
The file is a simple html5 template with a `<script>` block in the `<body>` section. The file is a simple html5 template with a `<script>` block in the `<body>` section.
The script block imports the requested view and will render it. The script block imports the requested view and will render it.
The view entry is a function, named in PascalCase, which __must__ be be exported by default (`export default function MyReactView(args: {..})`). The view entry is a function, named in PascalCase, which **must** be be exported by default (`export default function MyReactView(args: {..})`).
```html ```html
<!-- <!--
@ -72,9 +76,9 @@ imports the given view URL, and assume that the view exports a function named `C
see ViewRenderer.tsx::renderView for more info see ViewRenderer.tsx::renderView for more info
--> -->
<script type="module"> <script type="module">
import {renderView} from "<?= asset("ViewRenderer.tsx") ?>" import {renderView} from "<?= asset("ViewRenderer.tsx") ?>"
import Component from "<?= asset($url) ?>" import Component from "<?= asset($url) ?>"
renderView(Component, <?= json_encode($arguments) ?>) renderView(Component, <?= json_encode($arguments) ?>)
</script> </script>
``` ```
@ -86,6 +90,7 @@ This method then uses the `send_react_front`, to render the `views/SampleForm.ts
The view file **must export by default its react function component**. The view file **must export by default its react function component**.
## Server Profiles ## Server Profiles
If you go on the staging server, you'll see that, for the exact same request equivalent, the generated `src/render-display-file` file changes : If you go on the staging server, you'll see that, for the exact same request equivalent, the generated `src/render-display-file` file changes :
![](assets/staging-server-render-react-php-file-processed.png) ![](assets/staging-server-render-react-php-file-processed.png)
(we can also see that much less files are downloaded than with our localhost aka development server). (we can also see that much less files are downloaded than with our localhost aka development server).
@ -101,6 +106,7 @@ By default, the `/config.php` file uses the `dev-config-profile.php` profile,
the file is replaced with `prod-config-file.php` by the CI when deploying to the staging server (see the pipeline "prepare php" step in `/ci/.drone.yml`) the file is replaced with `prod-config-file.php` by the CI when deploying to the staging server (see the pipeline "prepare php" step in `/ci/.drone.yml`)
The two profiles declares an `_asset(string $uri)` function, used by the `/config.php::asset` method, but with different implementations : The two profiles declares an `_asset(string $uri)` function, used by the `/config.php::asset` method, but with different implementations :
### Development profile ### Development profile
```php ```php
@ -112,9 +118,11 @@ function _asset(string $assetURI): string {
return $front_url . "/" . $assetURI; return $front_url . "/" . $assetURI;
} }
``` ```
The simplest profile, simply redirect all assets to the development server The simplest profile, simply redirect all assets to the development server
### Production profile ### Production profile
Before the CD workflow step deploys the generated files to the server, Before the CD workflow step deploys the generated files to the server,
it generates a `/views-mappings.php` file that will map the react views file names to their compiled javascript files : it generates a `/views-mappings.php` file that will map the react views file names to their compiled javascript files :
@ -126,7 +134,9 @@ const ASSETS = [
... // other files that does not have to be directly used by the `send_react_front()` function ... // other files that does not have to be directly used by the `send_react_front()` function
]; ];
``` ```
The `_asset` function will then get the right javascript for the given typescript file. The `_asset` function will then get the right javascript for the given typescript file.
```php ```php
require "../views-mappings.php"; require "../views-mappings.php";

@ -1,7 +1,8 @@
# IQBall - Web Application # IQBall - Web Application
This repository hosts the IQBall application for web This repository hosts the IQBall application for web
## Read the docs ! ## Read the docs !
You can find some additional documentation in the [Documentation](Documentation) folder, You can find some additional documentation in the [Documentation](Documentation) folder,
and in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki). and in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki).

@ -3,66 +3,41 @@ type: docker
name: "CI and Deploy on maxou.dev" name: "CI and Deploy on maxou.dev"
volumes: volumes:
- name: server - name: server
temp: {} temp: {}
trigger: trigger:
event: event:
- push - push
steps: steps:
- image: node:latest
- image: node:latest name: "front CI"
name: "front CI" commands:
commands: - npm install
- npm install - npm run tsc
- npm run tsc
- image: node:latest
- image: composer:latest name: "build react"
name: "php CI" volumes: &outputs
commands: - name: server
- composer install path: /outputs
- vendor/bin/phpstan analyze depends_on:
- "front CI"
- image: node:latest commands:
name: "build node" - # force to use the backend master branch if pushing on master
volumes: &outputs - echo "VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-$([ "$DRONE_BRANCH" = master ] && echo master || cat .stage-backend-branch | tr / _)" > .env.STAGE
- name: server - npm run build -- --base=/$DRONE_BRANCH/ --mode STAGE
path: /outputs - mv dist/* /outputs
depends_on:
- "front CI" - image: eeacms/rsync:latest
commands: name: Deliver on staging server branch
- curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh depends_on:
- chmod +x /tmp/moshell_setup.sh - "build react"
- echo n | /tmp/moshell_setup.sh volumes: *outputs
- echo "VITE_API_ENDPOINT=/IQBall/$DRONE_BRANCH/public/api" >> .env.PROD environment:
- echo "VITE_BASE=/IQBall/$DRONE_BRANCH/public" >> .env.PROD SERVER_PRIVATE_KEY:
- apt update && apt install jq -y from_secret: SERVER_PRIVATE_KEY
- commands:
- BASE="/IQBall/$DRONE_BRANCH/public" OUTPUT=/outputs /root/.local/bin/moshell ci/build_react.msh - chmod +x ci/deploy.sh
- ci/deploy.sh
- image: ubuntu:latest
name: "prepare php"
volumes: *outputs
depends_on:
- "php CI"
commands:
- mkdir -p /outputs/public
# this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file.
- sed -E -i 's/\\/\\*PROFILE_FILE\\*\\/\\s*".*"/"profiles\\/prod-config-profile.php"/' config.php
- sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\\/IQBall\\/$DRONE_BRANCH\\/public\";/" profiles/prod-config-profile.php
- rm profiles/dev-config-profile.php
- mv src config.php sql profiles vendor /outputs/
- image: eeacms/rsync:latest
name: Deliver on staging server branch
depends_on:
- "prepare php"
- "build node"
volumes: *outputs
environment:
SERVER_PRIVATE_KEY:
from_secret: SERVER_PRIVATE_KEY
commands:
- chmod +x ci/deploy.sh
- ci/deploy.sh

@ -1,17 +1,19 @@
#!/usr/bin/env bash
set -xeu
export OUTPUT=$1 export OUTPUT=$1
export BASE=$2 export BASE=$2
rm -rf $OUTPUT/* rm -rf "$OUTPUT"/*
echo "VITE_API_ENDPOINT=$BASE/api" >> .env.PROD echo "VITE_API_ENDPOINT=$BASE/api" >> .env.PROD
echo "VITE_BASE=$BASE" >> .env.PROD echo "VITE_BASE=$BASE" >> .env.PROD
ci/build_react.msh ci/build_react.msh
mkdir -p $OUTPUT/profiles/ mkdir -p "$OUTPUT"/profiles/
sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > $OUTPUT/config.php sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > "$OUTPUT"/config.php
sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php
cp -r vendor sql src public $OUTPUT cp -r vendor sql src public "$OUTPUT"

@ -1,11 +1,11 @@
set -e set -eu
mkdir ~/.ssh mkdir ~/.ssh
echo "$SERVER_PRIVATE_KEY" > ~/.ssh/id_rsa echo "$SERVER_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 0600 ~/.ssh chmod 0600 ~/.ssh
chmod 0500 ~/.ssh/id_rsa* chmod 0500 ~/.ssh/id_rsa*
SERVER_ROOT=/srv/www/IQBall SERVER_ROOT=/srv/www/iqball
ssh -p 80 -o 'StrictHostKeyChecking=no' iqball@maxou.dev mkdir -p $SERVER_ROOT/$DRONE_BRANCH ssh -p 80 -o 'StrictHostKeyChecking=no' iqball@maxou.dev mkdir -p $SERVER_ROOT/$DRONE_BRANCH
rsync -avz -e "ssh -p 80 -o 'StrictHostKeyChecking=no'" --delete /outputs/* iqball@maxou.dev:$SERVER_ROOT/$DRONE_BRANCH rsync -avz -e "ssh -p 80 -o 'StrictHostKeyChecking=no'" --delete /outputs/* iqball@maxou.dev:$SERVER_ROOT/$DRONE_BRANCH

@ -1,18 +0,0 @@
{
"autoload": {
"psr-4": {
"IQBall\\": "src/"
}
},
"require": {
"altorouter/altorouter": "1.2.0",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"twig/twig":"^2.0",
"phpstan/phpstan": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.38"
}
}

@ -1,33 +0,0 @@
<?php
// `dev-config-profile.php` by default.
// on production server the included profile is `prod-config-profile.php`.
// Please do not touch.
require /*PROFILE_FILE*/ "profiles/dev-config-profile.php";
const SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH;
/**
* Maps the given relative source uri (relative to the `/front` folder) to its actual location depending on imported profile.
* @param string $assetURI relative uri path from `/front` folder
* @return string valid url that points to the given uri
*/
function asset(string $assetURI): string {
return _asset($assetURI);
}
function get_base_path(): string {
return _get_base_path();
}
global $_data_source_name;
$data_source_name = $_data_source_name;
const DATABASE_USER = _DATABASE_USER;
const DATABASE_PASSWORD = _DATABASE_PASSWORD;
function init_database(PDO $pdo): void {
_init_database($pdo);
}

@ -1,9 +0,0 @@
#!/usr/bin/env bash
## verify php and typescript types
echo "formatting php typechecking"
vendor/bin/php-cs-fixer fix
echo "formatting typescript typechecking"
npm run format

@ -1,16 +0,0 @@
import { API } from "./Constants"
export function fetchAPI(
url: string,
payload: unknown,
method = "POST",
): Promise<Response> {
return fetch(`${API}/${url}`, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
}

@ -1,19 +0,0 @@
import ReactDOM from "react-dom/client"
import React, { FunctionComponent } from "react"
/**
* Dynamically renders a React component, with given arguments
* @param Component the react component to render
* @param args the arguments to pass to the react component.
*/
export function renderView(Component: FunctionComponent, args: {}) {
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
)
root.render(
<React.StrictMode>
<Component {...args} />
</React.StrictMode>,
)
}

@ -1,135 +0,0 @@
<svg width="567" height="269" viewBox="0 0 567 269" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="73" y1="24" x2="495" y2="24" stroke="black" stroke-width="2"/>
<line x1="494" y1="23" x2="494" y2="247" stroke="black" stroke-width="2"/>
<line x1="495" y1="248" x2="73" y2="248" stroke="black" stroke-width="2"/>
<line x1="72" y1="249" x2="72" y2="23" stroke="black" stroke-width="2"/>
<line x1="283.5" y1="23" x2="283.5" y2="247" stroke="black"/>
<g filter="url(#filter0_i_3_2)">
<circle cx="283.5" cy="135.5" r="27" stroke="black"/>
</g>
<line x1="73" y1="99.5" x2="158" y2="99.5" stroke="black"/>
<line x1="73" y1="100.5" x2="158" y2="100.5" stroke="black"/>
<path d="M158.5 172V100" stroke="black"/>
<path d="M158 99.5H159" stroke="black"/>
<path d="M158 172.5H159" stroke="black"/>
<line x1="158" y1="172.5" x2="73" y2="172.5" stroke="black"/>
<line x1="158" y1="171.5" x2="73" y2="171.5" stroke="black"/>
<line x1="73" y1="37.5" x2="139" y2="37.5" stroke="black"/>
<line x1="73" y1="233.5" x2="139" y2="233.5" stroke="black"/>
<g filter="url(#filter1_i_3_2)">
<path d="M158.5 163C161.98 163 165.426 162.315 168.641 160.983C171.856 159.651 174.778 157.699 177.238 155.238C179.699 152.778 181.651 149.856 182.983 146.641C184.315 143.426 185 139.98 185 136.5C185 133.02 184.315 129.574 182.983 126.359C181.651 123.144 179.699 120.222 177.238 117.762C174.778 115.301 171.856 113.349 168.641 112.017C165.426 110.685 161.98 110 158.5 110L158.5 136.5L158.5 163Z" stroke="black"/>
</g>
<g filter="url(#filter2_i_3_2)">
<path d="M158.5 110C155.02 110 151.574 110.685 148.359 112.017C145.144 113.349 142.222 115.301 139.762 117.762C137.301 120.222 135.349 123.144 134.017 126.359C132.685 129.574 132 133.02 132 136.5C132 139.98 132.685 143.426 134.017 146.641C135.349 149.856 137.301 152.778 139.762 155.238C142.222 157.699 145.144 159.651 148.359 160.983C151.574 162.315 155.02 163 158.5 163" stroke="black" stroke-dasharray="4 4"/>
</g>
<line x1="135.5" y1="177" x2="135.5" y2="172" stroke="black"/>
<line x1="123.5" y1="177" x2="123.5" y2="172" stroke="black"/>
<line x1="111.5" y1="177" x2="111.5" y2="172" stroke="black"/>
<line x1="99.5" y1="177" x2="99.5" y2="172" stroke="black"/>
<line x1="135.5" y1="100" x2="135.5" y2="95" stroke="black"/>
<line x1="123.5" y1="100" x2="123.5" y2="95" stroke="black"/>
<line x1="111.5" y1="100" x2="111.5" y2="95" stroke="black"/>
<line x1="99.5" y1="100" x2="99.5" y2="95" stroke="black"/>
<path d="M140.212 233.607C159.054 225.612 175.149 211.967 186.427 194.431C197.705 176.895 203.649 156.271 203.497 135.213C203.345 114.155 197.104 93.6242 185.574 76.2645C174.045 58.9047 157.755 45.5096 138.799 37.8066" stroke="black"/>
<path d="M140 233.5H141" stroke="black"/>
<path d="M139 233.5H140" stroke="black"/>
<path d="M90.5 118.5C95.0041 118.5 99.3266 120.34 102.516 123.621C105.706 126.901 107.5 131.354 107.5 136C107.5 140.646 105.706 145.099 102.516 148.379C99.3266 151.66 95.0041 153.5 90.5 153.5" stroke="black"/>
<circle cx="87.5" cy="136.5" r="3" stroke="black"/>
<line x1="83.5" y1="149" x2="83.5" y2="123" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(-1 0 0 1 494 100)" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(-1 0 0 1 494 101)" stroke="black"/>
<path d="M408.5 172V100" stroke="black"/>
<path d="M409 99.5H408" stroke="black"/>
<path d="M409 172.5H408" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(1 0 0 -1 409 172)" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(1 0 0 -1 409 171)" stroke="black"/>
<line y1="-0.5" x2="66" y2="-0.5" transform="matrix(-1 0 0 1 494 38)" stroke="black"/>
<line y1="-0.5" x2="66" y2="-0.5" transform="matrix(-1 0 0 1 494 234)" stroke="black"/>
<g filter="url(#filter3_i_3_2)">
<path d="M408.5 163C405.02 163 401.574 162.315 398.359 160.983C395.144 159.651 392.222 157.699 389.762 155.238C387.301 152.778 385.349 149.856 384.017 146.641C382.685 143.426 382 139.98 382 136.5C382 133.02 382.685 129.574 384.017 126.359C385.349 123.144 387.301 120.222 389.762 117.762C392.222 115.301 395.144 113.349 398.359 112.017C401.574 110.685 405.02 110 408.5 110L408.5 136.5L408.5 163Z" stroke="black"/>
</g>
<g filter="url(#filter4_i_3_2)">
<path d="M408.5 110C411.98 110 415.426 110.685 418.641 112.017C421.856 113.349 424.778 115.301 427.238 117.762C429.699 120.222 431.651 123.144 432.983 126.359C434.315 129.574 435 133.02 435 136.5C435 139.98 434.315 143.426 432.983 146.641C431.651 149.856 429.699 152.778 427.238 155.238C424.778 157.699 421.856 159.651 418.641 160.983C415.426 162.315 411.98 163 408.5 163" stroke="black" stroke-dasharray="4 4"/>
</g>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 431 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 443 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 455 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 467 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 431 100)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 443 100)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 455 100)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 467 100)" stroke="black"/>
<path d="M426.788 233.607C407.946 225.612 391.851 211.967 380.573 194.431C369.295 176.895 363.351 156.271 363.503 135.213C363.655 114.155 369.896 93.6242 381.426 76.2645C392.955 58.9047 409.245 45.5096 428.201 37.8066" stroke="black"/>
<path d="M427 233.5H426" stroke="black"/>
<path d="M428 233.5H427" stroke="black"/>
<g filter="url(#filter5_i_3_2)">
<path d="M476.5 118.5C471.996 118.5 467.673 120.34 464.484 123.621C461.294 126.901 459.5 131.354 459.5 136C459.5 140.646 461.294 145.099 464.484 148.379C467.673 151.66 471.996 153.5 476.5 153.5" stroke="black"/>
</g>
<circle cx="3.5" cy="3.5" r="3" transform="matrix(-1 0 0 1 483 133)" stroke="black"/>
<line y1="-0.5" x2="26" y2="-0.5" transform="matrix(0 -1 -1 0 483 149)" stroke="black"/>
<path d="M138 37.5H139" stroke="black"/>
<path d="M139.225 38.1519C139.221 38.1187 139.099 38.1364 139.073 38.135C138.98 38.1302 138.889 38.0674 138.794 38.0674C138.755 38.0674 138.672 38.0388 138.633 38.0252C138.581 38.0065 138.52 37.9719 138.465 37.9661C138.4 37.9593 138.366 37.9109 138.312 37.8854C138.275 37.8677 138.202 37.8366 138.177 37.8056C138.161 37.7847 138.063 37.7566 138.033 37.7371C137.962 37.6922 137.879 37.6309 137.806 37.5944" stroke="black" stroke-width="0.5"/>
<path d="M139.926 37.9883C139.906 37.9888 139.887 37.9922 139.867 37.9922C139.838 37.9922 139.812 37.978 139.787 37.9622C139.682 37.8946 139.599 37.7958 139.504 37.7151C139.388 37.6172 139.275 37.5101 139.135 37.4512C139.051 37.4159 138.965 37.3877 138.879 37.3594C138.85 37.3498 138.785 37.3113 138.754 37.332" stroke="black" stroke-width="0.5"/>
<defs>
<filter id="filter0_i_3_2" x="256" y="108" width="55" height="59" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter1_i_3_2" x="158" y="109.5" width="27.5" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter2_i_3_2" x="131.5" y="109.5" width="27" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter3_i_3_2" x="381.5" y="109.5" width="27.5" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter4_i_3_2" x="408.5" y="109.5" width="27" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter5_i_3_2" x="459" y="118" width="17.5" height="40" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

@ -1,73 +0,0 @@
<svg width="269" height="309" viewBox="0 0 269 309" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="24" y1="236" x2="24" y2="26" stroke="black" stroke-width="2"/>
<line x1="248" y1="25" x2="248" y2="236" stroke="black" stroke-width="2"/>
<line x1="249" y1="237" x2="23" y2="237" stroke="black" stroke-width="2"/>
<line x1="23" y1="25.5" x2="247" y2="25.5" stroke="black"/>
<g filter="url(#filter0_i_3_4)">
<path d="M108.5 26C108.5 33.0313 111.347 39.7727 116.411 44.7417C121.476 49.7103 128.342 52.5 135.5 52.5C142.658 52.5 149.524 49.7103 154.588 44.7417C159.653 39.7727 162.5 33.0313 162.5 26" stroke="black"/>
</g>
<line x1="99.5" y1="236" x2="99.5" y2="151" stroke="black"/>
<line x1="100.5" y1="236" x2="100.5" y2="151" stroke="black"/>
<path d="M172 150.5H100" stroke="black"/>
<path d="M99.5 151V150" stroke="black"/>
<path d="M172.5 151V150" stroke="black"/>
<line x1="172.5" y1="151" x2="172.5" y2="236" stroke="black"/>
<line x1="171.5" y1="151" x2="171.5" y2="236" stroke="black"/>
<line x1="37.5" y1="236" x2="37.5" y2="170" stroke="black"/>
<line x1="233.5" y1="236" x2="233.5" y2="170" stroke="black"/>
<g filter="url(#filter1_i_3_4)">
<path d="M163 150.5C163 147.02 162.315 143.574 160.983 140.359C159.651 137.144 157.699 134.222 155.238 131.762C152.778 129.301 149.856 127.349 146.641 126.017C143.426 124.685 139.98 124 136.5 124C133.02 124 129.574 124.685 126.359 126.017C123.144 127.349 120.222 129.301 117.762 131.762C115.301 134.222 113.349 137.144 112.017 140.359C110.685 143.574 110 147.02 110 150.5L136.5 150.5L163 150.5Z" stroke="black"/>
</g>
<g filter="url(#filter2_i_3_4)">
<path d="M110 150.5C110 153.98 110.685 157.426 112.017 160.641C113.349 163.856 115.301 166.778 117.762 169.238C120.222 171.699 123.144 173.651 126.359 174.983C129.574 176.315 133.02 177 136.5 177C139.98 177 143.426 176.315 146.641 174.983C149.856 173.651 152.778 171.699 155.238 169.238C157.699 166.778 159.651 163.856 160.983 160.641C162.315 157.426 163 153.98 163 150.5" stroke="black" stroke-dasharray="4 4"/>
</g>
<line x1="177" y1="173.5" x2="172" y2="173.5" stroke="black"/>
<line x1="177" y1="185.5" x2="172" y2="185.5" stroke="black"/>
<line x1="177" y1="197.5" x2="172" y2="197.5" stroke="black"/>
<line x1="177" y1="209.5" x2="172" y2="209.5" stroke="black"/>
<line x1="100" y1="173.5" x2="95" y2="173.5" stroke="black"/>
<line x1="100" y1="185.5" x2="95" y2="185.5" stroke="black"/>
<line x1="100" y1="197.5" x2="95" y2="197.5" stroke="black"/>
<line x1="100" y1="209.5" x2="95" y2="209.5" stroke="black"/>
<path d="M233.607 168.788C225.612 149.946 211.967 133.851 194.431 122.573C176.895 111.295 156.271 105.351 135.213 105.503C114.155 105.655 93.6242 111.896 76.2645 123.426C58.9047 134.955 45.5096 151.245 37.8066 170.201" stroke="black"/>
<path d="M233.5 169V168" stroke="black"/>
<path d="M233.5 170V169" stroke="black"/>
<path d="M118.5 218.5C118.5 213.996 120.34 209.673 123.621 206.484C126.901 203.294 131.354 201.5 136 201.5C140.646 201.5 145.099 203.294 148.379 206.484C151.66 209.673 153.5 213.996 153.5 218.5" stroke="black"/>
<circle cx="136.5" cy="221.5" r="3" transform="rotate(-90 136.5 221.5)" stroke="black"/>
<line x1="149" y1="225.5" x2="123" y2="225.5" stroke="black"/>
<path d="M37.5 171V170" stroke="black"/>
<path d="M38.1519 169.775C38.1187 169.779 38.1364 169.901 38.135 169.927C38.1302 170.02 38.0674 170.111 38.0674 170.206C38.0674 170.245 38.0388 170.328 38.0252 170.367C38.0065 170.419 37.9719 170.48 37.9661 170.535C37.9593 170.6 37.9109 170.634 37.8854 170.688C37.8677 170.725 37.8366 170.798 37.8056 170.823C37.7847 170.839 37.7566 170.937 37.7371 170.967C37.6922 171.038 37.6309 171.121 37.5944 171.194" stroke="black" stroke-width="0.5"/>
<path d="M37.9883 169.074C37.9888 169.094 37.9922 169.113 37.9922 169.133C37.9922 169.162 37.978 169.188 37.9622 169.213C37.8946 169.318 37.7958 169.401 37.7151 169.496C37.6172 169.612 37.5101 169.725 37.4512 169.865C37.4159 169.949 37.3877 170.035 37.3594 170.121C37.3498 170.15 37.3113 170.215 37.332 170.246" stroke="black" stroke-width="0.5"/>
<defs>
<filter id="filter0_i_3_4" x="108" y="26" width="55" height="31" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_4"/>
</filter>
<filter id="filter1_i_3_4" x="109.5" y="123.5" width="54" height="31.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_4"/>
</filter>
<filter id="filter2_i_3_4" x="109.5" y="150.5" width="54" height="31" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_4"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 B

@ -1,5 +0,0 @@
<svg viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

@ -1,272 +0,0 @@
import { CourtBall } from "./CourtBall"
import {
ReactElement,
RefObject,
useCallback,
useLayoutEffect,
useState,
} from "react"
import CourtPlayer from "./CourtPlayer"
import { Player } from "../../model/tactic/Player"
import { Action, ActionKind } from "../../model/tactic/Action"
import ArrowAction from "../actions/ArrowAction"
import { middlePos, ratioWithinBase } from "../arrows/Pos"
import BallAction from "../actions/BallAction"
import { CourtObject } from "../../model/tactic/Ball"
import { contains } from "../arrows/Box"
import { CourtAction } from "../../views/editor/CourtAction"
export interface BasketCourtProps {
players: Player[]
actions: Action[]
objects: CourtObject[]
renderAction: (a: Action, key: number) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
onBallRemove: () => void
onBallMoved: (ball: DOMRect) => void
courtImage: ReactElement
courtRef: RefObject<HTMLDivElement>
}
export function BasketCourt({
players,
actions,
objects,
renderAction,
setActions,
onPlayerRemove,
onPlayerChange,
onBallMoved,
onBallRemove,
courtImage,
courtRef,
}: BasketCourtProps) {
function placeArrow(origin: Player, arrowHead: DOMRect) {
const originRef = document.getElementById(origin.id)!
const courtBounds = courtRef.current!.getBoundingClientRect()
const start = ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
)
for (const player of players) {
if (player.id == origin.id) {
continue
}
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
if (
!(
playerBounds.top > arrowHead.bottom ||
playerBounds.right < arrowHead.left ||
playerBounds.bottom < arrowHead.top ||
playerBounds.left > arrowHead.right
)
) {
const targetPos = document
.getElementById(player.id)!
.getBoundingClientRect()
const end = ratioWithinBase(middlePos(targetPos), courtBounds)
const action: Action = {
fromPlayerId: originRef.id,
toPlayerId: player.id,
type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN,
moveFrom: start,
segments: [{ next: end }],
}
setActions((actions) => [...actions, action])
return
}
}
const action: Action = {
fromPlayerId: originRef.id,
type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE,
moveFrom: ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
),
segments: [
{ next: ratioWithinBase(middlePos(arrowHead), courtBounds) },
],
}
setActions((actions) => [...actions, action])
}
const [previewAction, setPreviewAction] = useState<Action | null>(null)
const updateActionsRelatedTo = useCallback((player: Player) => {
const newPos = ratioWithinBase(
middlePos(
document.getElementById(player.id)!.getBoundingClientRect(),
),
courtRef.current!.getBoundingClientRect(),
)
setActions((actions) =>
actions.map((a) => {
if (a.fromPlayerId == player.id) {
return { ...a, moveFrom: newPos }
}
if (a.toPlayerId == player.id) {
const segments = a.segments.toSpliced(
a.segments.length - 1,
1,
{
...a.segments[a.segments.length - 1],
next: newPos,
},
)
return { ...a, segments }
}
return a
}),
)
}, [])
const [internActions, setInternActions] = useState<Action[]>([])
useLayoutEffect(() => setInternActions(actions), [actions])
return (
<div
className="court-container"
ref={courtRef}
style={{ position: "relative" }}>
{courtImage}
{players.map((player) => (
<CourtPlayer
key={player.id}
player={player}
onDrag={() => updateActionsRelatedTo(player)}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
courtRef={courtRef}
availableActions={(pieceRef) => [
<ArrowAction
key={1}
onHeadMoved={(headPos) => {
const baseBounds =
courtRef.current!.getBoundingClientRect()
const arrowHeadPos = middlePos(headPos)
const target = players.find(
(p) =>
p != player &&
contains(
document
.getElementById(p.id)!
.getBoundingClientRect(),
arrowHeadPos,
),
)
setPreviewAction((action) => ({
...action!,
segments: [
{
next: ratioWithinBase(
arrowHeadPos,
baseBounds,
),
},
],
type: player.hasBall
? target
? ActionKind.SHOOT
: ActionKind.DRIBBLE
: target
? ActionKind.SCREEN
: ActionKind.MOVE,
}))
}}
onHeadPicked={(headPos) => {
;(document.activeElement as HTMLElement).blur()
const baseBounds =
courtRef.current!.getBoundingClientRect()
setPreviewAction({
type: player.hasBall
? ActionKind.DRIBBLE
: ActionKind.MOVE,
fromPlayerId: player.id,
toPlayerId: undefined,
moveFrom: ratioWithinBase(
middlePos(
pieceRef.getBoundingClientRect(),
),
baseBounds,
),
segments: [
{
next: ratioWithinBase(
middlePos(headPos),
baseBounds,
),
},
],
})
}}
onHeadDropped={(headRect) => {
placeArrow(player, headRect)
setPreviewAction(null)
}}
/>,
player.hasBall && (
<BallAction
key={2}
onDrop={(ref) =>
onBallMoved(ref.getBoundingClientRect())
}
/>
),
]}
/>
))}
{internActions.map((action, idx) => renderAction(action, idx))}
{objects.map((object) => {
if (object.type == "ball") {
return (
<CourtBall
onMoved={onBallMoved}
ball={object}
onRemove={onBallRemove}
key="ball"
/>
)
}
throw new Error("unknown court object" + object.type)
})}
{previewAction && (
<CourtAction
courtRef={courtRef}
action={previewAction}
//do nothing on change, not really possible as it's a preview arrow
onActionDeleted={() => {}}
onActionChanges={() => {}}
/>
)}
</div>
)
}

@ -1,38 +0,0 @@
import React, { useRef } from "react"
import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece"
import { Ball } from "../../model/tactic/Ball"
export interface CourtBallProps {
onMoved: (rect: DOMRect) => void
onRemove: () => void
ball: Ball
}
export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null)
const x = ball.rightRatio
const y = ball.bottomRatio
return (
<Draggable
onStop={() => onMoved(pieceRef.current!.getBoundingClientRect())}
nodeRef={pieceRef}>
<div
className={"ball-div"}
ref={pieceRef}
tabIndex={0}
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<BallPiece />
</div>
</Draggable>
)
}

@ -1,82 +0,0 @@
import { ReactNode, RefObject, useRef } from "react"
import "../../style/player.css"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../model/tactic/Player"
import { NULL_POS, ratioWithinBase } from "../arrows/Pos"
export interface PlayerProps {
player: Player
onDrag: () => void
onChange: (p: Player) => void
onRemove: () => void
courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => ReactNode[]
}
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({
player,
onDrag,
onChange,
onRemove,
courtRef,
availableActions,
}: PlayerProps) {
const hasBall = player.hasBall
const x = player.rightRatio
const y = player.bottomRatio
const pieceRef = useRef<HTMLDivElement>(null)
return (
<Draggable
handle=".player-piece"
nodeRef={pieceRef}
onDrag={onDrag}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = courtRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(pieceBounds, parentBounds)
onChange({
id: player.id,
rightRatio: x,
bottomRatio: y,
team: player.team,
role: player.role,
hasBall: player.hasBall,
})
}}>
<div
id={player.id}
ref={pieceRef}
className="player"
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div
tabIndex={0}
className="player-content"
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}>
<div className="player-actions">
{availableActions(pieceRef.current!)}
</div>
<PlayerPiece
team={player.team}
text={player.role}
hasBall={hasBall}
/>
</div>
</div>
</Draggable>
)
}

@ -1,19 +0,0 @@
import { Pos } from "../../components/arrows/Pos"
import { Segment } from "../../components/arrows/BendableArrow"
import { PlayerId } from "./Player"
export enum ActionKind {
SCREEN = "SCREEN",
DRIBBLE = "DRIBBLE",
MOVE = "MOVE",
SHOOT = "SHOOT",
}
export type Action = { type: ActionKind } & MovementAction
export interface MovementAction {
fromPlayerId: PlayerId
toPlayerId?: PlayerId
moveFrom: Pos
segments: Segment[]
}

@ -1,17 +0,0 @@
export type CourtObject = { type: "ball" } & Ball
export interface Ball {
/**
* The ball is a "ball" court object
*/
readonly type: "ball"
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
readonly bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
readonly rightRatio: number
}

@ -1,35 +0,0 @@
export type PlayerId = string
export enum PlayerTeam {
Allies = "allies",
Opponents = "opponents",
}
export interface Player {
readonly id: PlayerId
/**
* the player's team
* */
readonly team: PlayerTeam
/**
* player's role
* */
readonly role: string
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
readonly bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
readonly rightRatio: number
/**
* True if the player has a basketball
*/
readonly hasBall: boolean
}

@ -1,15 +0,0 @@
import { Player } from "./Player"
import { CourtObject } from "./Ball"
import { Action } from "./Action"
export interface Tactic {
id: number
name: string
content: TacticContent
}
export interface TacticContent {
players: Player[]
objects: CourtObject[]
actions: Action[]
}

@ -1,45 +0,0 @@
@import url(../theme/dark.css);
@import url(personnal_space.css);
@import url(side_menu.css);
@import url(../template/header.css);
body {
/* background-color: #303030; */
}
#main {
/* margin-left : 10%;
margin-right: 10%; */
/* border : solid 1px #303030; */
display: flex;
flex-direction: column;
font-family: var(--font-content);
height: 100%;
}
#body {
display: flex;
flex-direction: row;
margin: 0px;
height: 100%;
background-color: var(--second-color);
overflow: hidden;
}
.data {
border: 1.5px solid var(--main-contrast-color);
background-color: var(--main-color);
border-radius: 0.75cap;
color: var(--main-contrast-color);
position: relative;
}
.data:hover {
border-color: var(--accent-color);
cursor: pointer;
}
.set-button {
width: 80%;
margin-left: 5%;
margin-top: 5%;
}

@ -1,235 +0,0 @@
@import url(../style/theme/dark.css);
@import url(../style/template/header.css);
body {
margin: 0;
background-color: var(--second-color);
color: white;
}
#main-div {
display: flex;
flex-direction: column;
align-items: stretch;
font-family: var(--font-content);
height: 100vh;
}
#content-container {
display: flex;
flex-direction: row;
align-items: stretch;
height: 90vh;
margin: 10px 2% 0 2%;
}
#left-panel {
display: flex;
flex-direction: column;
align-items: center;
width: 68%;
margin-right: 2%;
margin-bottom: 15px;
}
#right-panel {
display: flex;
flex-direction: column;
align-items: center;
width: 30%;
flex-grow: 1;
}
header {
display: flex;
justify-content: center;
background-color: var(--main-color);
width: 100%;
height: 80%;
padding-bottom: 10px;
padding-top: 10px;
}
header h1 a {
color: var(--accent-color);
text-decoration: none;
font-size: 1.4em;
}
html,
body,
#main-div,
#content-container,
#right-panel,
#tactics {
height: 100%;
}
.square {
width: 50px;
height: 50px;
border: 2px white solid;
}
#team-info {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding-bottom: 10px;
border-radius: 10px;
background-color: var(--third-color);
}
#first-part {
display: flex;
flex-direction: column;
align-items: center;
}
#team-name {
font-size: 2.8em;
}
#logo {
aspect-ratio: 3/2;
object-fit: contain;
max-width: 60%; /* Adjusted max-width for the team logo */
max-height: 60vh; /* Added max-height for the team logo */
margin-bottom: 10px; /* Added margin at the bottom */
}
#colors {
display: flex;
flex-direction: column;
}
.color {
flex-direction: row;
justify-content: space-between;
}
#colorsTitle {
width: 110%;
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 1.3em;
color: white;
}
#actual-colors {
display: flex;
flex-direction: row;
justify-content: space-around;
}
#delete {
border-radius: 10px;
background-color: red;
color: white;
margin-top: 10px;
margin-bottom: 10px;
margin-right: 5px;
}
#edit {
border-radius: 10px;
background-color: orange;
color: white;
margin-top: 10px;
margin-bottom: 10px;
}
#head-members {
width: 33%;
display: flex;
flex-direction: row;
justify-content: space-evenly;
margin-bottom: 10px;
}
#add-member {
height: 30px;
aspect-ratio: 1/1;
border-radius: 100%;
align-self: center;
}
#members {
display: flex;
flex-direction: column;
background-color: var(--third-color);
width: 100%;
align-items: center;
justify-content: space-around;
border-radius: 10px;
}
.member {
width: 60%;
background-color: #494b5d;
display: flex;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
border-radius: 10px;
margin-top: 5px;
margin-bottom: 5px;
}
#profile-picture {
height: 40px;
width: 40px;
}
#tactics {
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--third-color);
width: 100%;
height: 100%;
border-radius: 10px;
overflow: auto;
margin-bottom: 5px;
}
#head-tactics {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-evenly;
margin-bottom: 10px;
}
.tactic {
box-sizing: border-box;
width: 100%;
background-color: #494b5d;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 10px;
margin-top: 5px;
margin-bottom: 5px;
padding: 10px;
max-width: 90%;
cursor: pointer;
}
.tactic button#open-tactic {
border-radius: 10px;
background-color: green;
color: white;
padding: 5px 8px;
}
.tactic button#delete {
border-radius: 10px;
background-color: red;
color: white;
padding: 5px 8px;
margin-left: 10px;
}

@ -1,9 +0,0 @@
:root {
--main-color: #191a21;
--second-color: #282a36;
--third-color: #303341;
--accent-color: #ffa238;
--main-contrast-color: #e6edf3;
--font-title: Helvetica;
--font-content: Helvetica;
}

@ -1,30 +0,0 @@
#main {
height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
}
#topbar {
display: flex;
background-color: var(--main-color);
justify-content: center;
align-items: center;
}
h1 {
text-align: center;
margin-top: 0;
}
#court-container {
flex: 1;
display: flex;
justify-content: center;
background-color: var(--main-color);
}
#court {
max-width: 80%;
max-height: 80%;
}

@ -1,616 +0,0 @@
import {
CSSProperties,
Dispatch,
SetStateAction,
useCallback,
useMemo,
useRef,
useState,
} from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import PlainCourt from "../assets/court/full_court.svg?react"
import HalfCourt from "../assets/court/half_court.svg?react"
import { BallPiece } from "../components/editor/BallPiece"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import { Player } from "../model/tactic/Player"
import { Tactic, TacticContent } from "../model/tactic/Tactic"
import { fetchAPI } from "../Fetcher"
import { PlayerTeam } from "../model/tactic/Player"
import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
import { CourtObject } from "../model/tactic/Ball"
import { CourtAction } from "./editor/CourtAction"
import { BasketCourt } from "../components/editor/BasketCourt"
import { ratioWithinBase } from "../components/arrows/Pos"
import { Action, ActionKind } from "../model/tactic/Action"
import { BASE } from "../Constants"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
}
const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export interface EditorViewProps {
tactic: Tactic
onContentChange: (tactic: TacticContent) => Promise<SaveState>
onNameChange: (name: string) => Promise<boolean>
courtType: "PLAIN" | "HALF"
}
export interface EditorProps {
id: number
name: string
content: string
courtType: "PLAIN" | "HALF"
}
/**
* information about a player that is into a rack
*/
interface RackedPlayer {
team: PlayerTeam
key: string
}
type RackedCourtObject = { key: "ball" }
export default function Editor({ id, name, courtType, content }: EditorProps) {
const isInGuestMode = id == -1
const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
const editorContent =
isInGuestMode && storage_content != null ? storage_content : content
const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY)
const editorName =
isInGuestMode && storage_name != null ? storage_name : name
return (
<EditorView
tactic={{
name: editorName,
id,
content: JSON.parse(editorContent),
}}
onContentChange={async (content: TacticContent) => {
if (isInGuestMode) {
localStorage.setItem(
GUEST_MODE_CONTENT_STORAGE_KEY,
JSON.stringify(content),
)
return SaveStates.Guest
}
return fetchAPI(`tactic/${id}/save`, { content }).then((r) =>
r.ok ? SaveStates.Ok : SaveStates.Err,
)
}}
onNameChange={async (name: string) => {
if (isInGuestMode) {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}
return fetchAPI(`tactic/${id}/edit/name`, { name }).then(
(r) => r.ok,
)
}}
courtType={courtType}
/>
)
}
function EditorView({
tactic: { id, name, content: initialContent },
onContentChange,
onNameChange,
courtType,
}: EditorViewProps) {
const isInGuestMode = id == -1
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [content, setContent, saveState] = useContentState(
initialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
useMemo(() => debounceAsync(onContentChange),
[onContentChange],
),
)
const [allies, setAllies] = useState(
getRackPlayers(PlayerTeam.Allies, content.players),
)
const [opponents, setOpponents] = useState(
getRackPlayers(PlayerTeam.Opponents, content.players),
)
const [objects, setObjects] = useState<RackedCourtObject[]>(
isBallOnCourt(content) ? [] : [{ key: "ball" }],
)
const courtDivContentRef = useRef<HTMLDivElement>(null)
const isBoundsOnCourt = (bounds: DOMRect) => {
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
// check if refBounds overlaps courtBounds
return !(
bounds.top > courtBounds.bottom ||
bounds.right < courtBounds.left ||
bounds.bottom < courtBounds.top ||
bounds.left > courtBounds.right
)
}
const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
setContent((content) => {
return {
...content,
players: [
...content.players,
{
id: "player-" + element.key + "-" + element.team,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
hasBall: false,
},
],
actions: content.actions,
}
})
}
const onObjectDetach = (
ref: HTMLDivElement,
rackedObject: RackedCourtObject,
) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
switch (rackedObject.key) {
case "ball":
const ballObj = content.objects.findIndex(
(o) => o.type == "ball",
)
const playerCollidedIdx = getPlayerCollided(
refBounds,
content.players,
)
if (playerCollidedIdx != -1) {
onBallDropOnPlayer(playerCollidedIdx)
setContent((content) => {
return {
...content,
objects: content.objects.toSpliced(ballObj, 1),
}
})
return
}
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
}
break
default:
throw new Error("unknown court object " + rackedObject.key)
}
setContent((content) => {
return {
...content,
objects: [...content.objects, courtObject],
}
})
}
const getPlayerCollided = (
bounds: DOMRect,
players: Player[],
): number | -1 => {
for (let i = 0; i < players.length; i++) {
const player = players[i]
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
const doesOverlap = !(
bounds.top > playerBounds.bottom ||
bounds.right < playerBounds.left ||
bounds.bottom < playerBounds.top ||
bounds.left > playerBounds.right
)
if (doesOverlap) {
return i
}
}
return -1
}
function updateActions(actions: Action[], players: Player[]) {
return actions.map((action) => {
const originHasBall = players.find(
(p) => p.id == action.fromPlayerId,
)!.hasBall
let type = action.type
if (originHasBall && type == ActionKind.MOVE) {
type = ActionKind.DRIBBLE
} else if (originHasBall && type == ActionKind.SCREEN) {
type = ActionKind.SHOOT
} else if (type == ActionKind.DRIBBLE) {
type = ActionKind.MOVE
} else if (type == ActionKind.SHOOT) {
type = ActionKind.SCREEN
}
return {
...action,
type,
}
})
}
const onBallDropOnPlayer = (playerCollidedIdx: number) => {
setContent((content) => {
const ballObj = content.objects.findIndex((o) => o.type == "ball")
let player = content.players.at(playerCollidedIdx) as Player
const players = content.players.toSpliced(playerCollidedIdx, 1, {
...player,
hasBall: true,
})
return {
...content,
actions: updateActions(content.actions, players),
players,
objects: content.objects.toSpliced(ballObj, 1),
}
})
}
const onBallDrop = (refBounds: DOMRect) => {
if (!isBoundsOnCourt(refBounds)) {
removeCourtBall()
return
}
const playerCollidedIdx = getPlayerCollided(refBounds, content.players)
if (playerCollidedIdx != -1) {
setContent((content) => {
return {
...content,
players: content.players.map((player) => ({
...player,
hasBall: false,
})),
}
})
onBallDropOnPlayer(playerCollidedIdx)
return
}
if (content.objects.findIndex((o) => o.type == "ball") != -1) {
return
}
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
}
const players = content.players.map((player) => ({
...player,
hasBall: false,
}))
setContent((content) => {
return {
...content,
actions: updateActions(content.actions, players),
players,
objects: [...content.objects, courtObject],
}
})
}
const removePlayer = (player: Player) => {
setContent((content) => ({
...content,
players: toSplicedPlayers(content.players, player, false),
objects: [...content.objects],
actions: content.actions.filter(
(a) =>
a.toPlayerId !== player.id && a.fromPlayerId !== player.id,
),
}))
let setter
switch (player.team) {
case PlayerTeam.Opponents:
setter = setOpponents
break
case PlayerTeam.Allies:
setter = setAllies
}
if (player.hasBall) {
setObjects([{ key: "ball" }])
}
setter((players) => [
...players,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
}
const removeCourtBall = () => {
setContent((content) => {
const ballObj = content.objects.findIndex((o) => o.type == "ball")
return {
...content,
players: content.players.map((player) => ({
...player,
hasBall: false,
})),
objects: content.objects.toSpliced(ballObj, 1),
}
})
setObjects([{ key: "ball" }])
}
return (
<div id="main-div">
<div id="topbar-div">
<button onClick={() => (location.pathname = BASE + "/")}>
Home
</button>
<div id="topbar-left">
<SavingState state={saveState} />
</div>
<div id="title-input-div">
<TitleInput
style={titleStyle}
default_value={name}
on_validated={(new_name) => {
onNameChange(new_name).then((success) => {
setTitleStyle(success ? {} : ERROR_STYLE)
})
}}
/>
</div>
<div id="topbar-right" />
</div>
<div id="edit-div">
<div id="racks">
<Rack
id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/>
<Rack
id={"objects"}
objects={objects}
onChange={setObjects}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onObjectDetach}
render={renderCourtObject}
/>
<Rack
id="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/>
</div>
<div id="court-div">
<div id="court-div-bounds">
<BasketCourt
players={content.players}
objects={content.objects}
actions={content.actions}
onBallMoved={onBallDrop}
courtImage={<Court courtType={courtType} />}
courtRef={courtDivContentRef}
setActions={(actions) =>
setContent((content) => ({
...content,
players: content.players,
actions: actions(content.actions),
}))
}
renderAction={(action, i) => (
<CourtAction
key={i}
action={action}
courtRef={courtDivContentRef}
onActionDeleted={() => {
setContent((content) => ({
...content,
actions: content.actions.toSpliced(
i,
1,
),
}))
}}
onActionChanges={(a) =>
setContent((content) => ({
...content,
actions: content.actions.toSpliced(
i,
1,
a,
),
}))
}
/>
)}
onPlayerChange={(player) => {
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
if (!isBoundsOnCourt(playerBounds)) {
removePlayer(player)
return
}
setContent((content) => ({
...content,
players: toSplicedPlayers(
content.players,
player,
true,
),
}))
}}
onPlayerRemove={removePlayer}
onBallRemove={removeCourtBall}
/>
</div>
</div>
</div>
</div>
)
}
function isBallOnCourt(content: TacticContent) {
if (content.players.findIndex((p) => p.hasBall) != -1) {
return true
}
return content.objects.findIndex((o) => o.type == "ball") != -1
}
function renderCourtObject(courtObject: RackedCourtObject) {
if (courtObject.key == "ball") {
return <BallPiece />
}
throw new Error("unknown racked court object " + courtObject.key)
}
function Court({ courtType }: { courtType: string }) {
return (
<div id="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt id="court-image" />
) : (
<HalfCourt id="court-image" />
)}
</div>
)
}
function getRackPlayers(team: PlayerTeam, players: Player[]): RackedPlayer[] {
return ["1", "2", "3", "4", "5"]
.filter(
(role) =>
players.findIndex((p) => p.team == team && p.role == role) ==
-1,
)
.map((key) => ({ team, key }))
}
function debounceAsync<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
): (args: A) => Promise<B> {
let task = 0
return (args: A) => {
clearTimeout(task)
return new Promise((resolve, reject) => {
task = setTimeout(() => f(args).then(resolve).catch(reject), delay)
})
}
}
function useContentState<S>(
initialContent: S,
initialSaveState: SaveState,
saveStateCallback: (s: S) => Promise<SaveState>,
): [S, Dispatch<SetStateAction<S>>, SaveState] {
const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback(
(newState: SetStateAction<S>) => {
setContent((content) => {
const state =
typeof newState === "function"
? (newState as (state: S) => S)(content)
: newState
if (state !== content) {
setSavingState(SaveStates.Saving)
saveStateCallback(state)
.then(setSavingState)
.catch(() => setSavingState(SaveStates.Err))
}
return state
})
},
[saveStateCallback],
)
return [content, setContentSynced, savingState]
}
function toSplicedPlayers(
players: Player[],
player: Player,
replace: boolean,
): Player[] {
const idx = players.findIndex(
(p) => p.team === player.team && p.role === player.role,
)
return players.toSpliced(idx, 1, ...(replace ? [player] : []))
}

@ -1,427 +0,0 @@
import "../style/home/home.css"
import { Header } from "./template/Header"
import { BASE } from "../Constants"
import Draggable from "react-draggable"
import { NULL_POS } from "../components/arrows/Pos"
import { contains } from "../components/arrows/Box"
import React, { useRef, useState } from "react"
import { fetchAPI } from "../Fetcher"
import { User } from "../model/User"
import { FaShare } from "react-icons/fa"
import { SaveStates } from "../components/editor/SavingState"
interface Tactic {
id: number
name: string
creation_date: string
}
interface Team {
id: number
name: string
picture: string
main_color: string
second_color: string
}
export default function Home({
lastTactics,
allTactics,
teams,
user,
}: {
lastTactics: Tactic[]
allTactics: Tactic[]
teams: Team[]
user: User
}) {
return (
<div id="main">
<Header user={user} />
<Body
lastTactics={lastTactics}
allTactics={allTactics}
teams={teams}
user={user}
/>
</div>
)
}
function Body({
lastTactics,
allTactics,
teams,
user,
}: {
lastTactics: Tactic[]
allTactics: Tactic[]
teams: Team[]
user: User
}) {
const widthPersonalSpace = 78
const widthSideMenu = 100 - widthPersonalSpace
return (
<div id="body">
<PersonalSpace
width={widthPersonalSpace}
allTactics={allTactics}
teams={teams}
user={user}
/>
<SideMenu
width={widthSideMenu}
lastTactics={lastTactics}
teams={teams}
/>
</div>
)
}
function SideMenu({
width,
lastTactics,
teams,
}: {
width: number
lastTactics: Tactic[]
teams: Team[]
}) {
return (
<div
id="side-menu"
style={{
width: width + "%",
}}>
<div id="side-menu-content">
<TeamList teams={teams} />
<TacticList lastTactics={lastTactics} />
</div>
</div>
)
}
function PersonalSpace({
width,
allTactics,
teams,
user,
}: {
width: number
allTactics: Tactic[]
teams: Team[]
user: User
}) {
return (
<div
id="personal-space"
style={{
width: width + "%",
}}>
<TitlePersonalSpace />
<BodyPersonalSpace
allTactics={allTactics}
teams={teams}
user={user}
/>
</div>
)
}
function TitlePersonalSpace() {
return (
<div id="title-personal-space">
<h2>Espace Personnel</h2>
</div>
)
}
function TableData({
allTactics,
teams,
user,
}: {
allTactics: Tactic[]
teams: Team[]
user: User
}) {
const nbRow = Math.floor(allTactics.length / 3) + 1
let listTactic = Array(nbRow)
for (let i = 0; i < nbRow; i++) {
listTactic[i] = Array(0)
}
let i = 0
let j = 0
allTactics.forEach((tactic) => {
listTactic[i].push(tactic)
j++
if (j === 3) {
i++
j = 0
}
})
i = 0
while (i < nbRow) {
listTactic[i] = listTactic[i].map((tactic: Tactic, index: number) => (
<DraggableTableDataElement
key={index}
tactic={tactic}
teams={teams}
/>
))
i++
}
if (nbRow == 1) {
if (listTactic[0].length < 3) {
for (let i = 0; i <= 3 - listTactic[0].length; i++) {
listTactic[0].push(<td key={"tdNone" + i}></td>)
}
}
}
const data = listTactic.map((tactic, rowIndex) => (
<tr key={rowIndex + "row"}>{tactic}</tr>
))
return data
}
function DraggableTableDataElement({
tactic,
teams,
}: {
tactic: Tactic
teams: Team[]
}) {
const ref = useRef<HTMLDivElement>(null)
const [dragging, setDragging] = useState(false)
const [hovered, setHovered] = useState(false)
const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (!dragging) {
const userEmail = window.prompt(
"Entrez l'email à qui partager la tactique :",
)
if (userEmail != null) {
onShareTactic(userEmail, tactic)
}
} else {
setDragging(false)
}
}
return (
<Draggable
position={NULL_POS}
nodeRef={ref}
onDrag={() => setDragging(true)}
onStop={() => {
if (dragging) {
if (ref.current) {
onDropTactic(
ref.current.getBoundingClientRect(),
tactic,
teams,
)
}
}
}}>
<td
key={tactic.id}
ref={ref as React.RefObject<HTMLTableDataCellElement>}
className="data"
onClick={() => {
if (!dragging) {
location.pathname =
BASE + "/tactic/" + tactic.id + "/edit"
} else {
setDragging(false)
}
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}>
{truncateString(tactic.name, 25)}
{hovered && (
<div className="share-icon-container">
<button
className="share-button share-icon-button"
onClick={handleButtonClick}>
<FaShare className="share-icon" />
</button>
</div>
)}
</td>
</Draggable>
)
}
function BodyPersonalSpace({
allTactics,
teams,
user,
}: {
allTactics: Tactic[]
teams: Team[]
user: User
}) {
let data
if (allTactics.length == 0) {
data = <p>Aucune tactique créée !</p>
} else {
data = <TableData allTactics={allTactics} teams={teams} user={user} />
}
return (
<div id="body-personal-space">
<table>
<tbody key="tbody">{data}</tbody>
</table>
</div>
)
}
function TeamList({ teams }: { teams: Team[] }) {
return (
<div id="teams">
<div className="titre-side-menu">
<h2 className="title">Mes équipes</h2>
<button
className="new"
onClick={() => (location.pathname = BASE + "/team/new")}>
+
</button>
</div>
<SetButtonTeam teams={teams} />
</div>
)
}
function TacticList({ lastTactics }: { lastTactics: Tactic[] }) {
return (
<div id="tactic">
<div className="titre-side-menu">
<h2 className="title">Mes dernières stratégies</h2>
<button
className="new"
id="create-tactic"
onClick={() => (location.pathname = BASE + "/tactic/new")}>
+
</button>
</div>
<SetButtonTactic tactics={lastTactics} />
</div>
)
}
function SetButtonTactic({ tactics }: { tactics: Tactic[] }) {
const lastTactics = tactics.map((tactic, i) => (
<ButtonLastTactic key={i} tactic={tactic} />
))
return <div className="set-button">{lastTactics}</div>
}
function SetButtonTeam({ teams }: { teams: Team[] }) {
const listTeam = teams.map((teams, i) => (
<ButtonTeam key={i} team={teams} />
))
return <div className="set-button">{listTeam}</div>
}
function ButtonTeam({ team }: { team: Team }) {
const name = truncateString(team.name, 20)
return (
<div>
<div
id={"button-team-" + team.id}
className="button-side-menu data"
onClick={() => {
location.pathname = BASE + "/team/" + team.id
}}>
{name}
</div>
</div>
)
}
function ButtonLastTactic({ tactic }: { tactic: Tactic }) {
const name = truncateString(tactic.name, 20)
return (
<div
id={"button" + tactic.id}
className="button-side-menu data"
onClick={() => {
location.pathname = BASE + "/tactic/" + tactic.id + "/edit"
}}>
{name}
</div>
)
}
function truncateString(name: string, limit: number): string {
if (name.length > limit) {
name = name.substring(0, limit) + "..."
}
return name
}
function onDropTactic(ref: DOMRect, tactic: Tactic, teams: Team[]) {
let shared = false
for (const team of teams) {
if (
contains(
ref,
document
.getElementById(`button-team-${team.id}`)!
.getBoundingClientRect(),
)
) {
if (!shared) {
shareTacticToTeam(tactic, team)
shared = true
}
}
}
}
async function onShareTactic(email: string, tactic: Tactic) {
const canShareResponse = await fetchAPI(
`tactic/${tactic.id}/can-share`,
tactic,
)
if (canShareResponse.ok) {
const shareToAccountResponse = await fetchAPI(
`tactic/${tactic.id}/share-to-account`,
{ email },
)
if (!shareToAccountResponse.ok) {
alert(
"Une erreur s'est produite lors du partage de la tactique avec ce compte",
)
}
} else {
alert("Vous ne pouvez pas partager cette tactique")
}
}
async function shareTacticToTeam(tactic: Tactic, team: Team) {
const canShare = await fetchAPI(
`tactic/${tactic.id}/can-share-to-team`,
team,
).then((r) => r.ok)
if (
canShare &&
confirm(
"Etes-vous sûr de vouloir partager la tactique " +
tactic.name +
" avec l'équipe " +
team.name,
)
) {
fetchAPI(`tactic/${tactic.id}/share-to-team`, team)
}
if (!canShare) {
alert("Vous ne pouvez pas partager cette tactique à cette équipe")
}
}

@ -1,23 +0,0 @@
import React, { CSSProperties, useState } from "react"
import "../style/visualizer.css"
import Court from "../assets/court/full_court.svg"
export default function Visualizer({ id, name }: { id: number; name: string }) {
const [style, setStyle] = useState<CSSProperties>({})
return (
<div id="main">
<div id="topbar">
<h1>{name}</h1>
</div>
<div id="court-container">
<img
id="court"
src={Court}
style={style}
alt="Basketball Court"
/>
</div>
</div>
)
}

@ -1,40 +0,0 @@
import { BASE } from "../../Constants"
import { User } from "../../model/User"
/**
*
* @param param0 username
* @returns Header
*/
export function Header({ user }: { user: User }) {
return (
<div id="header">
<div id="header-left"></div>
<div id="header-center">
<h1
id="iqball"
className="clickable"
onClick={() => {
location.pathname = BASE + "/"
}}>
<span id="IQ">IQ</span>
<span id="Ball">Ball</span>
</h1>
</div>
<div id="header-right">
<div className="clickable" id="clickable-header-right">
<img
id="img-account"
src={user.profilePicture}
onClick={() => {
location.pathname = BASE + "/settings"
}}
alt="photo de profil"
/>
<p id="username">{user.name}</p>
</div>
</div>
</div>
)
}

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IQBall</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -1,39 +1,43 @@
{ {
"name": "iqball_web", "name": "iqball_web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "type": "module",
"@testing-library/jest-dom": "^5.17.0", "dependencies": {
"@testing-library/react": "^13.4.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/react": "^13.4.0",
"@types/jest": "^27.5.2", "@testing-library/user-event": "^13.5.0",
"@types/node": "^16.18.59", "@types/react": "^18.2.31",
"@types/react": "^18.2.31", "@types/react-dom": "^18.2.14",
"@types/react-dom": "^18.2.14", "eslint-plugin-react-refresh": "^0.4.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-draggable": "^4.4.6", "react-drag-drop-files": "^2.3.10",
"react-icons": "^5.0.1", "react-draggable": "^4.4.6",
"typescript": "^5.2.2", "react-router-dom": "^6.22.0",
"vite": "^4.5.0", "typescript": "^5.2.2",
"vite-plugin-css-injected-by-js": "^3.3.0" "vite": "^4.5.0",
}, "vite-plugin-css-injected-by-js": "^3.3.0"
"scripts": { },
"start": "vite --host", "scripts": {
"build": "vite build", "start": "vite --host",
"test": "vite test", "build": "vite build",
"format": "prettier --config .prettierrc 'front' --write", "test": "vitest",
"tsc": "tsc" "format": "prettier --config .prettierrc '.' --write",
}, "tsc": "tsc"
"devDependencies": { },
"@vitejs/plugin-react": "^4.1.0", "devDependencies": {
"prettier": "^3.1.0", "@typescript-eslint/eslint-plugin": "^6.11.0",
"typescript": "^5.2.2", "@typescript-eslint/parser": "^6.11.0",
"vite-plugin-svgr": "^4.1.0", "@vitejs/plugin-react": "^4.1.0",
"@typescript-eslint/eslint-plugin": "^6.11.0", "eslint": "^8.53.0",
"@typescript-eslint/parser": "^6.11.0", "eslint-plugin-react": "^7.33.2",
"eslint": "^8.53.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react": "^7.33.2", "jsdom": "^24.0.0",
"eslint-plugin-react-hooks": "^4.6.0" "prettier": "^3.1.0",
} "rollup-plugin-visualizer": "^5.12.0",
"typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0",
"vitest": "^1.3.1"
}
} }

@ -1,12 +0,0 @@
parameters:
phpVersion: 70400
level: 6
paths:
- src
scanFiles:
- config.php
- sql/database.php
- profiles/dev-config-profile.php
- profiles/prod-config-profile.php
excludePaths:
- src/App/react-display-file.php

@ -1,43 +0,0 @@
<?php
use IQBall\Core\Connection;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Model\AuthModel;
$hostname = getHostName();
$front_url = "http://$hostname:5173";
const _SUPPORTS_FAST_REFRESH = true;
$_data_source_name = "sqlite:${_SERVER['DOCUMENT_ROOT']}/../dev-database.sqlite";
// no user and password needed for sqlite databases
const _DATABASE_USER = null;
const _DATABASE_PASSWORD = null;
function _asset(string $assetURI): string {
global $front_url;
return $front_url . "/" . $assetURI;
}
function _init_database(PDO $pdo): void {
$accounts = new AccountGateway(new Connection($pdo));
$teams = new \IQBall\Core\Gateway\TeamGateway((new Connection($pdo)));
$defaultAccounts = ["maxime", "mael", "yanis", "vivien"];
$defaultTeams = ["Lakers", "Celtics", "Bulls"];
foreach ($defaultAccounts as $name) {
$email = "$name@mail.com";
$id = $accounts->insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT), "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png");
$accounts->setIsAdmin($id, true);
}
foreach ($defaultTeams as $name) {
$id = $teams->insert($name, "https://lebasketographe.fr/wp-content/uploads/2019/11/nom-equipes-nba.jpg", "#1a2b3c", "#FF00AA");
}
}
function _get_base_path(): string {
return "";
}

@ -1,32 +0,0 @@
<?php
// This file only exists on production servers, and defines the available assets mappings
// in an `ASSETS` array constant.
require __DIR__ . "/../views-mappings.php";
// THIS VALUE IS TO SET IN THE CI
const BASE_PATH = null;
const _SUPPORTS_FAST_REFRESH = false;
$database_file = __DIR__ . "/../database.sqlite";
$_data_source_name = "sqlite:/$database_file";
// no user and password needed for sqlite databases
const _DATABASE_USER = null;
const _DATABASE_PASSWORD = null;
function _asset(string $assetURI): string {
// use index.php's base path
global $basePath;
// If the asset uri does not figure in the available assets array,
// fallback to the uri itself.
return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI);
}
function _init_database(PDO $pdo): void {}
function _get_base_path(): string {
return BASE_PATH;
}

@ -1,4 +0,0 @@
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.*$ ./index.php [NC,L,QSA]

@ -1,4 +0,0 @@
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.*$ ./index.php [NC,L,QSA]

@ -1,110 +0,0 @@
<?php
require "../../config.php";
require "../../vendor/autoload.php";
require "../../sql/database.php";
use IQBall\Api\API;
use IQBall\Api\Controller\APIAccountsController;
use IQBall\Api\Controller\APIAuthController;
use IQBall\Api\Controller\APIServerController;
use IQBall\Api\Controller\APITacticController;
use IQBall\App\Session\PhpSessionHandle;
use IQBall\Core\Action;
use IQBall\Core\Connection;
use IQBall\Core\Data\Account;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Gateway\TacticInfoGateway;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Gateway\TeamGateway;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Gateway\MemberGateway;
$basePath = get_base_path() . "/api";
function getTacticController(): APITacticController {
return new APITacticController(
new TacticModel(new TacticInfoGateway(new Connection(get_database())), new AccountGateway(new Connection(get_database()))),
new TeamModel(new TeamGateway(new Connection(get_database())), new MemberGateway(new Connection(get_database())), new AccountGateway(new Connection(get_database())))
);
}
function getAuthController(): APIAuthController {
return new APIAuthController(new AuthModel(new AccountGateway(new Connection(get_database()))));
}
function getAccountController(): APIAccountsController {
$con = new Connection(get_database());
$gw = new AccountGateway($con);
return new APIAccountsController(new AuthModel($gw), $gw);
}
function getServerController(): APIServerController {
global $basePath;
return new APIServerController($basePath, get_database());
}
function getAPITeamController(): \IQBall\Api\Controller\APITeamController {
$con = new Connection(get_database());
return new \IQBall\Api\Controller\APITeamController(new \IQBall\Core\Model\TeamModel(new \IQBall\Core\Gateway\TeamGateway($con), new \IQBall\Core\Gateway\MemberGateway($con), new AccountGateway($con)));
}
function getRoutes(): AltoRouter {
global $basePath;
$router = new AltoRouter();
$router->setBasePath($basePath);
$router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize()));
$router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc)));
$router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc)));
$router->map("GET", "/admin/list-users", Action::noAuth(fn() => getAccountController()->listUsers($_GET)));
$router->map("GET", "/admin/user/[i:id]", Action::noAuth(fn(int $id) => getAccountController()->getUser($id)));
$router->map("GET", "/admin/user/[i:id]/space", Action::noAuth(fn(int $id) => getTacticController()->getUserTactics($id)));
$router->map("POST", "/admin/user/add", Action::noAuth(fn() => getAccountController()->addUser()));
$router->map("POST", "/admin/user/remove-all", Action::noAuth(fn() => getAccountController()->removeUsers()));
$router->map("POST", "/admin/user/[i:id]/update", Action::noAuth(fn(int $id) => getAccountController()->updateUser($id)));
$router->map("GET", "/admin/server-info", Action::noAuth(fn() => getServerController()->getServerInfo()));
$router->map("GET", "/admin/list-team", Action::noAuth(fn() => getAPITeamController()->listTeams($_GET)));
$router->map("POST", "/admin/add-team", Action::noAuth(fn() => getAPITeamController()->addTeam()));
$router->map("POST", "/admin/delete-teams", Action::noAuth(fn() => getAPITeamController()->deleteTeamSelected()));
$router->map("POST", "/admin/team/[i:id]/update", Action::noAuth(fn(int $id) => getAPITeamController()->updateTeam($id)));
$router->map("POST", "/tactic/[i:id]/can-share", Action::auth(fn(int $id, Account $acc) => getTacticController()->canShareTactic($id, $acc)));
$router->map("POST", "/tactic/[i:id]/can-share-to-team", Action::auth(fn(int $id, Account $acc) => getTacticController()->canShareTacticToTeam($id, $acc)));
$router->map("POST", "/tactic/[i:id]/share-to-team", Action::auth(fn(int $id, Account $acc) => getTacticController()->shareTacticToTeam($id, $acc)));
$router->map("POST", "/tactic/[i:id]/share-to-account", Action::auth(fn(int $id, Account $acc) => getTacticController()->shareTacticToAccount($id, $acc)));
$router->map("POST", "/tactic/[i:id]/unshare-to-team", Action::auth(fn(int $id, Account $acc) => getTacticController()->unshareTacticToTeam($id, $acc)));
return $router;
}
/**
* Defines the way of being authorised through the API
* By checking if an Authorisation header is set, and by expecting its value to be a valid token of an account.
* If the header is not set, fallback to the App's PHP session system, and try to extract the account from it.
* @return Account|null
* @throws Exception
*/
function tryGetAuthorization(): ?Account {
$headers = getallheaders();
// If no authorization header is set, try fallback to php session.
if (!isset($headers['Authorization'])) {
$session = PhpSessionHandle::init();
return $session->getAccount();
}
$token = $headers['Authorization'];
$gateway = new AccountGateway(new Connection(get_database()));
return $gateway->getAccountFromToken($token);
}
Api::consume(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization()));

@ -1 +0,0 @@
../front/assets

@ -1 +0,0 @@
../front

@ -1,146 +0,0 @@
<?php
require "../vendor/autoload.php";
require "../config.php";
require "../sql/database.php";
require "../src/App/react-display.php";
use IQBall\App\App;
use IQBall\App\Controller\AuthController;
use IQBall\App\Controller\EditorController;
use IQBall\App\Controller\TeamController;
use IQBall\App\Controller\UserController;
use IQBall\App\Controller\VisualizerController;
use IQBall\App\Controller\TacticController;
use IQBall\App\Session\MutableSessionHandle;
use IQBall\App\Session\PhpSessionHandle;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Action;
use IQBall\Core\Connection;
use IQBall\Core\Data\CourtType;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Gateway\MemberGateway;
use IQBall\Core\Gateway\TacticInfoGateway;
use IQBall\Core\Gateway\TeamGateway;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Validation\ValidationFail;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction;
function getConnection(): Connection {
return new Connection(get_database());
}
function getUserController(): UserController {
return new UserController(new TacticModel(new TacticInfoGateway(getConnection()), new AccountGateway(getConnection())), new TeamModel(new TeamGateway(getConnection()), new MemberGateway(getConnection()), new AccountGateway(getConnection())));
}
function getVisualizerController(): VisualizerController {
return new VisualizerController(new TacticModel(new TacticInfoGateway(getConnection()), new AccountGateway(getConnection())));
}
function getEditorController(): EditorController {
return new EditorController(new TacticModel(new TacticInfoGateway(getConnection()), new AccountGateway(getConnection())));
}
function getTeamController(): TeamController {
$con = getConnection();
return new TeamController(new TeamModel(new TeamGateway($con), new MemberGateway($con), new AccountGateway($con)));
}
function getTacticController(): TacticController {
return new TacticController(new TacticModel(new TacticInfoGateway(getConnection()), new AccountGateway(getConnection())), new TeamModel(new TeamGateway(getConnection()), new MemberGateway(getConnection()), new AccountGateway(getConnection())));
}
function getAuthController(): AuthController {
return new AuthController(new AuthModel(new AccountGateway(getConnection())));
}
function getTwig(): Environment {
global $basePath;
$fl = new FilesystemLoader("../src/App/Views");
$twig = new Environment($fl);
$twig->addFunction(new TwigFunction('path', fn(string $str) => "$basePath$str"));
return $twig;
}
function getRoutes(): AltoRouter {
global $basePath;
$ar = new AltoRouter();
$ar->setBasePath($basePath);
//authentication
$ar->map("GET", "/login", Action::noAuth(fn() => getAuthController()->displayLogin()));
$ar->map("GET", "/register", Action::noAuth(fn() => getAuthController()->displayRegister()));
$ar->map("POST", "/login", Action::noAuth(fn(SessionHandle $s) => getAuthController()->login($_POST, $s)));
$ar->map("POST", "/register", Action::noAuth(fn(SessionHandle $s) => getAuthController()->register($_POST, $s)));
//user-related
$ar->map("GET", "/", Action::auth(fn(SessionHandle $s) => getUserController()->home($s)));
$ar->map("GET", "/home", Action::auth(fn(SessionHandle $s) => getUserController()->home($s)));
$ar->map("GET", "/settings", Action::auth(fn(SessionHandle $s) => getUserController()->settings($s)));
$ar->map("GET", "/disconnect", Action::auth(fn(MutableSessionHandle $s) => getUserController()->disconnect($s)));
$ar->map("GET", "/shareTactic", Action::auth(fn(SessionHandle $s) => getTacticController()->displayTactic(true, $s)));
$ar->map("GET", "/shareTactic/[i:id]", Action::auth(fn(int $id, SessionHandle $s) => getTacticController()->displayTeamAndAccount(true, $id, $s)));
$ar->map("GET", "/shareTactic/[i:id]/team/[i:idTeam]", Action::auth(fn(int $tacticId, int $teamId, SessionHandle $s) => getTacticController()->displayShareConfirmation($tacticId, $teamId, $s)));
$ar->map("POST", "/shareTactic/[i:id]/team/[i:idTeam]", Action::auth(fn(int $tacticId, int $teamId, SessionHandle $s) => getTacticController()->shareTacticToTeam($_POST, $tacticId, $teamId, $s)));
$ar->map("POST", "/shareTactic/[i:id]/account", Action::auth(fn(int $tacticId, SessionHandle $s) => getTacticController()->shareTacticToAccount($_POST, $tacticId, $s)));
$ar->map("GET", "/unshareTactic", Action::auth(fn(SessionHandle $s) => getTacticController()->displayTactic(false, $s)));
$ar->map("GET", "/unshareTactic/[i:id]", Action::auth(fn(int $id, SessionHandle $s) => getTacticController()->displayTeamAndAccount(false, $id, $s)));
$ar->map("POST", "/unshareTactic/[i:id]", Action::auth(fn(int $id, SessionHandle $s) => getTacticController()->unshareTactic($id, $s)));
//tactic-related
$ar->map("GET", "/tactic/[i:id]/view", Action::auth(fn(int $id, SessionHandle $s) => getVisualizerController()->openVisualizer($id, $s)));
$ar->map("GET", "/tactic/[i:id]/edit", Action::auth(fn(int $id, SessionHandle $s) => getEditorController()->openEditor($id, $s)));
// don't require an authentication to run this action.
// If the user is not connected, the tactic will never save.
$ar->map("GET", "/tactic/new", Action::noAuth(fn() => getEditorController()->createNew()));
$ar->map("GET", "/tactic/new/plain", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNewOfKind(CourtType::plain(), $s)));
$ar->map("GET", "/tactic/new/half", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNewOfKind(CourtType::half(), $s)));
//team-related
$ar->map("GET", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->displayCreateTeam($s)));
$ar->map("POST", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->submitTeam($_POST, $s)));
$ar->map("GET", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->displayListTeamByName($s)));
$ar->map("POST", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->listTeamByName($_POST, $s)));
$ar->map("GET", "/team/[i:id]", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->displayTeam($id, $s)));
$ar->map("GET", "/team/[i:id]/delete", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->deleteTeamById($id, $s)));
$ar->map("GET", "/team/[i:id]/addMember", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->displayAddMember($id, $s)));
$ar->map("POST", "/team/[i:id]/addMember", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->addMember($id, $_POST, $s)));
$ar->map("GET", "/team/[i:idTeam]/remove/[i:idMember]", Action::auth(fn(int $idTeam, int $idMember, SessionHandle $s) => getTeamController()->deleteMember($idTeam, $idMember, $s)));
$ar->map("GET", "/team/[i:id]/edit", Action::auth(fn(int $idTeam, SessionHandle $s) => getTeamController()->displayEditTeam($idTeam, $s)));
$ar->map("POST", "/team/[i:id]/edit", Action::auth(fn(int $idTeam, SessionHandle $s) => getTeamController()->editTeam($idTeam, $_POST, $s)));
return $ar;
}
function runMatch($match, MutableSessionHandle $session): HttpResponse {
global $basePath;
if (!$match) {
return ViewHttpResponse::twig("error.html.twig", [
'failures' => [ValidationFail::notFound("Could not find page {$_SERVER['REQUEST_URI']}.")],
], HttpCodes::NOT_FOUND);
}
return App::runAction($basePath . '/login', $match['target'], $match['params'], $session);
}
//this is a global variable
$basePath = get_base_path();
App::render(runMatch(getRoutes()->match(), PhpSessionHandle::init()), fn() => getTwig());

@ -1,32 +0,0 @@
<?php
use IQBall\Core\Connection;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Model\AuthModel;
/**
* @return PDO The PDO instance of the configuration's database connexion.
*/
function get_database(): PDO {
// defined by profiles.
global $data_source_name;
$pdo = new PDO($data_source_name, DATABASE_USER, DATABASE_PASSWORD, [PDO::ERRMODE_EXCEPTION]);
$database_exists = $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")->fetchColumn() > 0;
if ($database_exists) {
return $pdo;
}
foreach (scandir(__DIR__) as $file) {
if (preg_match("/.*\.sql$/i", $file)) {
$content = file_get_contents(__DIR__ . "/" . $file);
$pdo->exec($content);
}
}
init_database($pdo);
return $pdo;
}

@ -1,71 +0,0 @@
-- drop tables here
DROP TABLE IF EXISTS Account;
DROP TABLE IF EXISTS Tactic;
DROP TABLE IF EXISTS Team;
DROP TABLE IF EXISTS User;
DROP TABLE IF EXISTS Member;
DROP TABLE IF EXISTS TacticSharedTeam;
DROP TABLE IF EXISTS TacticSharedAccount;
CREATE TABLE Admins
(
id integer PRIMARY KEY REFERENCES Account
);
CREATE TABLE Account
(
id integer PRIMARY KEY AUTOINCREMENT,
email varchar UNIQUE NOT NULL,
username varchar NOT NULL,
token varchar UNIQUE NOT NULL,
hash varchar NOT NULL,
profile_picture varchar NOT NULL
);
CREATE TABLE Tactic
(
id integer PRIMARY KEY AUTOINCREMENT,
name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer NOT NULL,
content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL,
court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
FOREIGN KEY (owner) REFERENCES Account
);
CREATE TABLE Team
(
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name varchar NOT NULL,
picture varchar NOT NULL,
main_color varchar NOT NULL,
second_color varchar NOT NULL
);
CREATE TABLE Member
(
id_team integer NOT NULL,
id_user integer NOT NULL,
role text CHECK (role IN ('COACH', 'PLAYER')) NOT NULL,
PRIMARY KEY(id_team, id_user),
FOREIGN KEY (id_team) REFERENCES Team (id),
FOREIGN KEY (id_user) REFERENCES Account (id)
);
CREATE TABLE TacticSharedTeam
(
id_team integer NOT NULL,
id_tactic integer NOT NULL,
PRIMARY KEY(id_team, id_tactic),
FOREIGN KEY (id_team) REFERENCES Team (id),
FOREIGN KEY (id_tactic) REFERENCES Tactic (id)
);
CREATE TABLE TacticSharedAccount
(
id_account integer NOT NULL,
id_tactic integer NOT NULL,
PRIMARY KEY(id_account, id_tactic),
FOREIGN KEY (id_account) REFERENCES Account (id),
FOREIGN KEY (id_tactic) REFERENCES Tactic (id)
);

@ -1,65 +0,0 @@
<?php
namespace IQBall\Api;
use Exception;
use IQBall\Core\Action;
use IQBall\Core\Data\Account;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Validation\ValidationFail;
class API {
public static function consume(HttpResponse $response): void {
http_response_code($response->getCode());
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: *');
foreach ($response->getHeaders() as $header => $value) {
header("$header: $value");
}
if ($response instanceof JsonHttpResponse) {
header('Content-type: application/json');
echo $response->getJson();
} elseif (get_class($response) != HttpResponse::class) {
throw new Exception("API returned unknown Http Response");
}
}
/**
* @param array<string, mixed>|false $match
* @param callable(): Account $tryGetAuthorization function to return account authorisation for the given action (if required)
* @return HttpResponse
* @throws Exception
*/
public static function handleMatch($match, callable $tryGetAuthorization): HttpResponse {
if (!$match) {
return new JsonHttpResponse([ValidationFail::notFound("not found")], HttpCodes::NOT_FOUND);
}
$action = $match['target'];
if (!$action instanceof Action) {
throw new Exception("routed action is not an AppAction object.");
}
$account = null;
if ($action->getAuthType() != Action::NO_AUTH) {
$account = call_user_func($tryGetAuthorization);
if ($account == null) {
return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")], HttpCodes::UNAUTHORIZED);
}
if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) {
return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED);
}
}
return $action->run($match['params'], $account);
}
}

@ -1,45 +0,0 @@
<?php
namespace IQBall\Api;
use IQBall\Core\Control;
use IQBall\Core\ControlSchemaErrorResponseFactory;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Validation\Validator;
class APIControl {
private static function errorFactory(): ControlSchemaErrorResponseFactory {
return new class () implements ControlSchemaErrorResponseFactory {
public function apply(array $failures): HttpResponse {
return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST);
}
};
}
/**
* Runs given callback, if the request's payload json validates the given schema.
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
* The callback must accept an HttpRequest, and return an HttpResponse object.
* @return HttpResponse
*/
public static function runChecked(array $schema, callable $run): HttpResponse {
return Control::runChecked($schema, $run, self::errorFactory());
}
/**
* Runs given callback, if the given request data array validates the given schema.
* @param array<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
* The callback must accept an HttpRequest, and return an HttpResponse object.
* @return HttpResponse
*/
public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse {
return Control::runCheckedFrom($data, $schema, $run, self::errorFactory());
}
}

@ -1,109 +0,0 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\Api\APIControl;
use IQBall\Core\Data\Account;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Validation\DefaultValidators;
use IQBall\Core\Validation\ValidationFail;
class APIAccountsController {
private AccountGateway $accounts;
private AuthModel $authModel;
/**
* @param AuthModel $model
* @param AccountGateway $accounts
*/
public function __construct(AuthModel $model, AccountGateway $accounts) {
$this->accounts = $accounts;
$this->authModel = $model;
}
/**
* @param array<string, mixed> $request
* @return HttpResponse
*/
public function listUsers(array $request): HttpResponse {
return APIControl::runCheckedFrom($request, [
'start' => [DefaultValidators::isUnsignedInteger()],
'n' => [DefaultValidators::isIntInRange(0, 250)],
'search' => [DefaultValidators::lenBetween(0, 256)],
], function (HttpRequest $req) {
$accounts = $this->accounts->searchAccounts(intval($req['start']), intval($req['n']), $req["search"]);
$users = array_map(fn(Account $acc) => $acc->getUser(), $accounts);
return new JsonHttpResponse([
"users" => $users,
"totalCount" => $this->accounts->totalCount(),
]);
});
}
/**
* @param int $userId
* @return HttpResponse given user information.
*/
public function getUser(int $userId): HttpResponse {
$acc = $this->accounts->getAccount($userId);
if ($acc == null) {
return new JsonHttpResponse([ValidationFail::notFound("User not found")], HttpCodes::NOT_FOUND);
}
return new JsonHttpResponse($acc->getUser());
}
public function addUser(): HttpResponse {
return APIControl::runChecked([
"username" => [DefaultValidators::name()],
"email" => [DefaultValidators::email()],
"password" => [DefaultValidators::password()],
"isAdmin" => [DefaultValidators::bool()],
], function (HttpRequest $req) {
$model = new AuthModel($this->accounts);
$account = $model->register($req["username"], $req["password"], $req["email"]);
if ($account == null) {
return new JsonHttpResponse([new ValidationFail("already exists", "An account with provided email ")], HttpCodes::FORBIDDEN);
}
return new JsonHttpResponse([
"id" => $account->getUser()->getId(),
]);
});
}
public function removeUsers(): HttpResponse {
return APIControl::runChecked([
"identifiers" => [DefaultValidators::array(), DefaultValidators::forall(DefaultValidators::isUnsignedInteger())],
], function (HttpRequest $req) {
$this->accounts->removeAccounts($req["identifiers"]);
return HttpResponse::fromCode(HttpCodes::OK);
});
}
public function updateUser(int $id): HttpResponse {
return APIControl::runChecked([
"email" => [DefaultValidators::email()],
"username" => [DefaultValidators::name()],
"isAdmin" => [DefaultValidators::bool()],
], function (HttpRequest $req) use ($id) {
$mailAccount = $this->accounts->getAccount($id);
if ($mailAccount->getUser()->getId() != $id) {
return new JsonHttpResponse([new ValidationFail("email exists", "The provided mail address already exists for another account.")], HttpCodes::FORBIDDEN);
}
$this->authModel->update($id, $req["email"], $req["username"], $req["isAdmin"]);
return HttpResponse::fromCode(HttpCodes::OK);
});
}
}

@ -1,44 +0,0 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\Api\APIControl;
use IQBall\App\Control;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Validation\DefaultValidators;
class APIAuthController {
private AuthModel $model;
/**
* @param AuthModel $model
*/
public function __construct(AuthModel $model) {
$this->model = $model;
}
/**
* From given email address and password, authenticate the user and respond with its authorization token.
* @return HttpResponse
*/
public function authorize(): HttpResponse {
return APIControl::runChecked([
"email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)],
"password" => [DefaultValidators::password()],
], function (HttpRequest $req) {
$failures = [];
$account = $this->model->login($req["email"], $req["password"], $failures);
if (!empty($failures)) {
return new JsonHttpResponse($failures, HttpCodes::UNAUTHORIZED);
}
return new JsonHttpResponse(["authorization" => $account->getToken()]);
});
}
}

@ -1,45 +0,0 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
class APIServerController {
private string $basePath;
private \PDO $pdo;
/**
* @param string $basePath
* @param \PDO $pdo
*/
public function __construct(string $basePath, \PDO $pdo) {
$this->basePath = $basePath;
$this->pdo = $pdo;
}
private function countLines(string $table): int {
$stmnt = $this->pdo->prepare("SELECT count(*) FROM $table");
$stmnt->execute();
$res = $stmnt->fetch(\PDO::FETCH_BOTH);
return $res[0];
}
/**
* @return HttpResponse some (useless) information about the server
*/
public function getServerInfo(): HttpResponse {
return new JsonHttpResponse([
'base_path' => $this->basePath,
'date' => (int) gettimeofday(true) * 1000,
'database' => [
'accounts' => $this->countLines("Account") . " line(s)",
'tactics' => $this->countLines("Tactic") . " line(s)",
'teams' => $this->countLines("Team") . " line(s)",
],
]);
}
}

@ -1,179 +0,0 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\Api\APIControl;
use IQBall\Core\Control;
use IQBall\Core\Data\Account;
use IQBall\Core\Data\TacticInfo;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Validation\FieldValidationFail;
use IQBall\Core\Validation\Validators;
/**
* API endpoint related to tactics
*/
class APITacticController {
private TacticModel $tacticModel;
private TeamModel $teamModel;
/**
* @param TacticModel $tacticModel
* @param TeamModel $teamModel
*/
public function __construct(TacticModel $tacticModel, TeamModel $teamModel) {
$this->tacticModel = $tacticModel;
$this->teamModel = $teamModel;
}
/**
* update name of tactic, specified by tactic identifier, given in url.
* @param int $tactic_id
* @param Account $account
* @return HttpResponse
*/
public function updateName(int $tactic_id, Account $account): HttpResponse {
return APIControl::runChecked([
"name" => [DefaultValidators::lenBetween(1, 50), DefaultValidators::nameWithSpaces()],
], function (HttpRequest $request) use ($tactic_id, $account) {
$failures = $this->tacticModel->updateName($tactic_id, $request["name"], $account->getUser()->getId());
if (!empty($failures)) {
//TODO find a system to handle Unauthorized error codes more easily from failures.
return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST);
}
return HttpResponse::fromCode(HttpCodes::OK);
});
}
/**
* @param int $id
* @param Account $account
* @return HttpResponse
*/
public function saveContent(int $id, Account $account): HttpResponse {
return APIControl::runChecked([
"content" => [],
], function (HttpRequest $req) use ($id) {
if ($fail = $this->tacticModel->updateContent($id, json_encode($req["content"]))) {
return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST);
}
return HttpResponse::fromCode(HttpCodes::OK);
});
}
/**
* @param int $tacticId
* @param Account $account
* @return HttpResponse
*/
public function canShareTactic(int $tacticId, Account $account): HttpResponse {
if ($this->tacticModel->canShareTactic($tacticId, $account)) {
return HttpResponse::fromCode(HttpCodes::OK);
}
return new JsonHttpResponse(["message" => "Vous ne pouvez pas partager cette tactique"], HttpCodes::FORBIDDEN);
}
/**
* @param int $tacticId
* @param Account $account
* @return HttpResponse
*/
public function canShareTacticToTeam(int $tacticId, Account $account): HttpResponse {
return Control::runChecked([
"id" => [],
"name" => [],
"picture" => [],
"main_color" => [],
"second_color" => [],
], function (HttpRequest $request) use ($tacticId, $account) {
if ($this->canShareTactic($tacticId, $account)->getCode() == HttpCodes::OK) {
if ($this->teamModel->canShareTacticToTeam($request["id"], $account->getUser()->getEmail())) {
return HttpResponse::fromCode(HttpCodes::OK);
}
}
return new JsonHttpResponse(["message" => "Action non autorisée"], HttpCodes::FORBIDDEN);
});
}
/**
* @param int $tacticId
* @param Account $account
* @return HttpResponse
*/
public function shareTacticToTeam(int $tacticId, Account $account): HttpResponse {
return Control::runChecked([
"id" => [],
"name" => [],
"picture" => [],
"main_color" => [],
"second_color" => [],
], function (HttpRequest $request) use ($tacticId) {
$this->teamModel->shareTacticToTeam($request["id"], $tacticId);
return HttpResponse::fromCode(HttpCodes::OK);
});
}
/**
* @param int $tacticId
* @param Account $account
* @return HttpResponse
*/
public function shareTacticToAccount(int $tacticId, Account $account): HttpResponse {
return Control::runChecked([
"email" => [],
], function (HttpRequest $request) use ($tacticId) {
$this->tacticModel->shareTacticToAccountMail($request["email"], $tacticId);
return HttpResponse::fromCode(HttpCodes::OK);
});
}
/**
* @param int $tacticId
* @param Account $account
* @return HttpResponse
*/
public function unshareTacticToTeam(int $tacticId, Account $account): HttpResponse {
return Control::runChecked([
"id" => [],
"name" => [],
"picture" => [],
"main_color" => [],
"second_color" => [],
], function (HttpRequest $request) use ($tacticId, $account) {
if ($this->teamModel->canShareTacticToTeam($request["id"], $account->getUser()->getEmail())) {
$this->teamModel->unshareTacticToTeam($tacticId, $request["id"]);
return HttpResponse::fromCode(HttpCodes::OK);
}
return new JsonHttpResponse(["message" => "Action non autorisée"], HttpCodes::FORBIDDEN);
});
}
/**
* @param int $userId
* @return HttpResponse given user information.
*/
public function getUserTactics(int $userId): HttpResponse {
$tactics = $this->tacticModel->listAllOf($userId);
$response = array_map(fn(TacticInfo $t) => [
'id' => $t->getId(),
'name' => $t->getName(),
'court' => $t->getCourtType(),
'creation_date' => $t->getCreationDate(),
], $tactics);
return new JsonHttpResponse($response);
}
}

@ -1,79 +0,0 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\Api\APIControl;
use IQBall\Core\Data\Account;
use IQBall\Core\Data\Team;
use IQBall\Core\Data\TeamInfo;
use IQBall\Core\Gateway\TeamGateway;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Validation\DefaultValidators;
class APITeamController {
private TeamModel $teamModel;
/**
* @param TeamModel $teamModel
*/
public function __construct(TeamModel $teamModel) {
$this->teamModel = $teamModel;
}
/**
* @param array<string, mixed> $req_params
* @return HttpResponse
*/
public function listTeams(array $req_params): HttpResponse {
return APIControl::runCheckedFrom($req_params, [
'start' => [DefaultValidators::isUnsignedInteger()],
'n' => [DefaultValidators::isUnsignedInteger()],
], function (HttpRequest $req) {
$teams = $this->teamModel->listAll(intval($req['start']), intval($req['n']));
return new JsonHttpResponse([
"totalCount" => $this->teamModel->countTeam(),
"teams" => $teams,
]);
});
}
public function addTeam(): HttpResponse {
return APIControl::runChecked([
'name' => [DefaultValidators::name()],
'picture' => [DefaultValidators::isURL()],
'mainColor' => [DefaultValidators::hexColor()],
'secondaryColor' => [DefaultValidators::hexColor()],
], function (HttpRequest $req) {
$this->teamModel->createTeam($req['name'], $req['picture'], $req['mainColor'], $req['secondaryColor']);
return HttpResponse::fromCode(HttpCodes::OK);
});
}
public function deleteTeamSelected(): HttpResponse {
return APIControl::runChecked([
'teams' => [],
], function (HttpRequest $req) {
$this->teamModel->deleteTeamSelected($req['teams']);
return HttpResponse::fromCode(HttpCodes::OK);
});
}
public function updateTeam(int $id): HttpResponse {
return APIControl::runChecked([
'name' => [DefaultValidators::name()],
'picture' => [DefaultValidators::isURL()],
'mainColor' => [DefaultValidators::hexColor()],
'secondaryColor' => [DefaultValidators::hexColor()],
], function (HttpRequest $req) {
$this->teamModel->editTeam($req['id'], $req['name'], $req['picture'], $req['mainColor'], $req['secondaryColor']);
return HttpResponse::fromCode(HttpCodes::OK);
});
}
}

@ -0,0 +1,275 @@
import {
BrowserRouter,
Navigate,
Outlet,
Route,
Routes,
useLocation,
} from "react-router-dom"
import { Header } from "./pages/template/Header.tsx"
import "./style/app.css"
import {
createContext,
lazy,
ReactNode,
Suspense,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react"
import { BASE } from "./Constants.ts"
import { Authentication, Fetcher } from "./app/Fetcher.ts"
import { User } from "./model/User.ts"
const HomePage = lazy(() => import("./pages/HomePage.tsx"))
const LoginPage = lazy(() => import("./pages/LoginPage.tsx"))
const RegisterPage = lazy(() => import("./pages/RegisterPage.tsx"))
const NotFoundPage = lazy(() => import("./pages/404.tsx"))
const CreateTeamPage = lazy(() => import("./pages/CreateTeamPage.tsx"))
const TeamPanelPage = lazy(() => import("./pages/TeamPanel.tsx"))
const NewTacticPage = lazy(() => import("./pages/NewTacticPage.tsx"))
const Editor = lazy(() => import("./pages/Editor.tsx"))
const VisualizerPage = lazy(() => import("./pages/VisualizerPage.tsx"))
const Settings = lazy(() => import("./pages/Settings.tsx"))
const TOKEN_REFRESH_INTERVAL_MS = 60 * 1000
export default function App() {
function suspense(node: ReactNode) {
return (
<Suspense fallback={<p>Loading, please wait...</p>}>
{node}
</Suspense>
)
}
const fetcher = useMemo(() => new Fetcher(getStoredAuthentication()), [])
const [user, setUser] = useState<User | null>(null)
const handleAuthSuccess = useCallback(
async (auth: Authentication) => {
fetcher.updateAuthentication(auth)
const user = await fetchUser(fetcher)
setUser(user)
storeAuthentication(auth)
},
[fetcher],
)
useEffect(() => {
const interval = setInterval(() => {
fetcher.fetchAPIGet("auth/keep-alive")
}, TOKEN_REFRESH_INTERVAL_MS)
return () => clearInterval(interval)
}, [fetcher])
return (
<div id="app">
<FetcherContext.Provider value={fetcher}>
<SignedInUserContext.Provider
value={{
user,
setUser,
}}>
<BrowserRouter basename={BASE}>
<Outlet />
<Routes>
<Route
path={"/login"}
element={suspense(
<LoginPage onSuccess={handleAuthSuccess} />,
)}
/>
<Route
path={"/register"}
element={suspense(
<RegisterPage
onSuccess={handleAuthSuccess}
/>,
)}
/>
<Route path={"/"} element={suspense(<AppLayout />)}>
<Route
path={"/"}
element={suspense(
<LoggedInPage>
<HomePage />
</LoggedInPage>,
)}
/>
<Route
path={"/home"}
element={suspense(
<LoggedInPage>
<HomePage />
</LoggedInPage>,
)}
/>
<Route
path={"/settings"}
element={suspense(
<LoggedInPage>
<Settings />
</LoggedInPage>,
)}
/>
<Route
path={"/team/new"}
element={suspense(<CreateTeamPage />)}
/>
<Route
path={"/team/:teamId"}
element={suspense(
<LoggedInPage>
<TeamPanelPage />
</LoggedInPage>,
)}
/>
<Route
path={"/tactic/new"}
element={suspense(<NewTacticPage />)}
/>
<Route
path={"/tactic/:tacticId/edit"}
element={suspense(
<LoggedInPage>
<Editor guestMode={false} />
</LoggedInPage>,
)}
/>
<Route
path={"/tactic/:tacticId/view"}
element={suspense(
<LoggedInPage>
<VisualizerPage guestMode={false} />
</LoggedInPage>,
)}
/>
<Route
path={"/tactic/view-guest"}
element={suspense(
<VisualizerPage guestMode={true} />,
)}
/>
<Route
path={"/tactic/edit-guest"}
element={suspense(
<Editor guestMode={true} />,
)}
/>
<Route
path={"*"}
element={suspense(<NotFoundPage />)}
/>
</Route>
</Routes>
</BrowserRouter>
</SignedInUserContext.Provider>
</FetcherContext.Provider>
</div>
)
}
async function fetchUser(fetcher: Fetcher): Promise<User> {
const response = await fetcher.fetchAPIGet("user")
if (!response.ok) {
throw Error(
"Could not retrieve user information : " + (await response.text()),
)
}
return await response.json()
}
const STORAGE_AUTH_KEY = "token"
function getStoredAuthentication(): Authentication {
const storedUser = localStorage.getItem(STORAGE_AUTH_KEY)
return storedUser == null ? null : JSON.parse(storedUser)
}
function storeAuthentication(auth: Authentication) {
localStorage.setItem(STORAGE_AUTH_KEY, JSON.stringify(auth))
}
interface LoggedInPageProps {
children: ReactNode
}
enum UserFetchingState {
FETCHING,
FETCHED,
ERROR,
}
function LoggedInPage({ children }: LoggedInPageProps) {
const [user, setUser] = useUser()
const fetcher = useAppFetcher()
const [userFetchingState, setUserFetchingState] = useState(
user === null ? UserFetchingState.FETCHING : UserFetchingState.FETCHED,
)
const location = useLocation()
useEffect(() => {
async function initUser() {
try {
const user = await fetchUser(fetcher)
setUser(user)
setUserFetchingState(UserFetchingState.FETCHED)
} catch (e) {
setUserFetchingState(UserFetchingState.ERROR)
}
}
if (userFetchingState === UserFetchingState.FETCHING) initUser()
}, [fetcher, setUser, userFetchingState])
switch (userFetchingState) {
case UserFetchingState.ERROR:
return (
<Navigate
to={"/login"}
replace
state={{ from: location.pathname }}
/>
)
case UserFetchingState.FETCHED:
return children
case UserFetchingState.FETCHING:
return <p>Fetching user...</p>
}
}
function AppLayout() {
return (
<>
<Header />
<Outlet />
</>
)
}
interface UserContext {
user: User | null
setUser: (user: User | null) => void
}
const SignedInUserContext = createContext<UserContext | null>(null)
const FetcherContext = createContext(new Fetcher())
export function useAppFetcher() {
return useContext(FetcherContext)
}
export function useUser(): [User | null, (user: User | null) => void] {
const { user, setUser } = useContext(SignedInUserContext)!
return [user, setUser]
}

@ -1,98 +0,0 @@
<?php
namespace IQBall\App;
use IQBall\App\Session\MutableSessionHandle;
use IQBall\Core\Action;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Validation\ValidationFail;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
class App {
/**
* renders (prints out) given HttpResponse to the client
* @param HttpResponse $response
* @param callable(): Environment $twigSupplier
* @return void
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
public static function render(HttpResponse $response, callable $twigSupplier): void {
http_response_code($response->getCode());
foreach ($response->getHeaders() as $header => $value) {
header("$header: $value");
}
if ($response instanceof ViewHttpResponse) {
self::renderView($response, $twigSupplier);
} elseif ($response instanceof JsonHttpResponse) {
header('Content-type: application/json');
echo $response->getJson();
}
}
/**
* renders (prints out) given ViewHttpResponse to the client
* @param ViewHttpResponse $response
* @param callable(): Environment $twigSupplier
* @return void
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
private static function renderView(ViewHttpResponse $response, callable $twigSupplier): void {
$file = $response->getFile();
$args = $response->getArguments();
switch ($response->getViewKind()) {
case ViewHttpResponse::REACT_VIEW:
send_react_front($file, $args);
break;
case ViewHttpResponse::TWIG_VIEW:
try {
$twig = call_user_func($twigSupplier);
$twig->display($file, $args);
} catch (RuntimeError|SyntaxError|LoaderError $e) {
http_response_code(500);
echo "There was an error rendering your view, please refer to an administrator.\nlogs date: " . date("YYYD, d M Y H:i:s");
throw $e;
}
break;
}
}
/**
* run a user action, and return the generated response
* @param string $authRoute the route towards an authentication page to response with a redirection
* if the run action requires auth but session does not contain a logged-in account.
* @param Action<MutableSessionHandle> $action
* @param mixed[] $params
* @param MutableSessionHandle $session
* @return HttpResponse
*/
public static function runAction(string $authRoute, Action $action, array $params, MutableSessionHandle $session): HttpResponse {
if ($action->getAuthType() != Action::NO_AUTH) {
$account = $session->getAccount();
if ($account == null) {
// put in the session the initial url the user wanted to get
$session->setInitialTarget($_SERVER['REQUEST_URI']);
return HttpResponse::redirectAbsolute($authRoute);
}
if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) {
return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED);
}
}
return $action->run($params, $session);
}
}

@ -1,44 +0,0 @@
<?php
namespace IQBall\App;
use IQBall\Core\Control;
use IQBall\Core\ControlSchemaErrorResponseFactory;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Validation\Validator;
class AppControl {
private static function errorFactory(): ControlSchemaErrorResponseFactory {
return new class () implements ControlSchemaErrorResponseFactory {
public function apply(array $failures): HttpResponse {
return ViewHttpResponse::twig("error.html.twig", ['failures' => $failures], HttpCodes::BAD_REQUEST);
}
};
}
/**
* Runs given callback, if the request's payload json validates the given schema.
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
* The callback must accept an HttpRequest, and return an HttpResponse object.
* @return HttpResponse
*/
public static function runChecked(array $schema, callable $run): HttpResponse {
return Control::runChecked($schema, $run, self::errorFactory());
}
/**
* Runs given callback, if the given request data array validates the given schema.
* @param array<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
* The callback must accept an HttpRequest, and return an HttpResponse object.
* @return HttpResponse
*/
public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse {
return Control::runCheckedFrom($data, $schema, $run, self::errorFactory());
}
}

@ -1,101 +0,0 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\MutableSessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Validation\DefaultValidators;
use IQBall\Core\Validation\FieldValidationFail;
class AuthController {
private AuthModel $model;
/**
* @param AuthModel $model
*/
public function __construct(AuthModel $model) {
$this->model = $model;
}
public function displayRegister(): HttpResponse {
return ViewHttpResponse::twig("display_register.html.twig", []);
}
/**
* registers given account
* @param mixed[] $request
* @param MutableSessionHandle $session
* @return HttpResponse
*/
public function register(array $request, MutableSessionHandle $session): HttpResponse {
$fails = [];
$request = HttpRequest::from($request, $fails, [
"username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)],
"password" => [DefaultValidators::password()],
"confirmpassword" => [DefaultValidators::password()],
"email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)],
]);
if (!empty($fails)) {
if (!(in_array($request['username'], $fails)) or !(in_array($request['email'], $fails))) {
return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails,'username' => $request['username'],'email' => $request['email']]);
}
}
if ($request["password"] != $request['confirmpassword']) {
$fails[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes.");
}
$account = $this->model->register($request['username'], $request["password"], $request['email']);
if (!$account) {
$fails[] = new FieldValidationFail("email", "L'email existe déjà");
}
if (!empty($fails)) {
return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]);
}
$session->setAccount($account);
$target_url = $session->getInitialTarget();
if ($target_url != null) {
return HttpResponse::redirectAbsolute($target_url);
}
return HttpResponse::redirect("/home");
}
public function displayLogin(): HttpResponse {
return ViewHttpResponse::twig("display_login.html.twig", []);
}
/**
* logins given account credentials
* @param mixed[] $request
* @param MutableSessionHandle $session
* @return HttpResponse
*/
public function login(array $request, MutableSessionHandle $session): HttpResponse {
$fails = [];
$account = $this->model->login($request['email'], $request['password'], $fails);
if (!empty($fails)) {
return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails]);
}
$session->setAccount($account);
$target_url = $session->getInitialTarget();
$session->setInitialTarget(null);
if ($target_url != null) {
return HttpResponse::redirectAbsolute($target_url);
}
return HttpResponse::redirect("/home");
}
}

@ -1,95 +0,0 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\Validator\TacticValidator;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Data\CourtType;
use IQBall\Core\Data\TacticInfo;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Validation\ValidationFail;
class EditorController {
private TacticModel $model;
public function __construct(TacticModel $model) {
$this->model = $model;
}
/**
* @param TacticInfo $tactic
* @return ViewHttpResponse the editor view for given tactic
*/
private function openEditorFor(TacticInfo $tactic): ViewHttpResponse {
return ViewHttpResponse::react("views/Editor.tsx", [
"id" => $tactic->getId(),
"name" => $tactic->getName(),
"content" => $tactic->getContent(),
"courtType" => $tactic->getCourtType()->name(),
]);
}
public function createNew(): ViewHttpResponse {
return ViewHttpResponse::react("views/NewTacticPanel.tsx", []);
}
/**
* @return ViewHttpResponse the editor view for a test tactic.
*/
private function openTestEditor(CourtType $courtType): ViewHttpResponse {
return ViewHttpResponse::react("views/Editor.tsx", [
"id" => -1, //-1 id means that the editor will not support saves
"name" => TacticModel::TACTIC_DEFAULT_NAME,
"content" => '{"players": [], "objects": [], "actions": []}',
"courtType" => $courtType->name(),
]);
}
/**
* creates a new empty tactic, with default name
* If the given session does not contain a connected account,
* open a test editor.
* @param SessionHandle $session
* @param CourtType $type
* @return ViewHttpResponse the editor view
*/
public function createNewOfKind(CourtType $type, SessionHandle $session): ViewHttpResponse {
$action = $session->getAccount();
if ($action == null) {
return $this->openTestEditor($type);
}
$tactic = $this->model->makeNewDefault($session->getAccount()->getUser()->getId(), $type);
return $this->openEditorFor($tactic);
}
/**
* returns an editor view for a given tactic
* @param int $id the targeted tactic identifier
* @param SessionHandle $session
* @return ViewHttpResponse
*/
public function openEditor(int $id, SessionHandle $session): ViewHttpResponse {
$tactic = $this->model->get($id);
$ownerId = $session->getAccount()->getUser()->getId();
$ids = $this->model->getOwnerIdTacticShared($id, $session->getAccount()->getUser()->getId());
if(isset($ids)) {
$ownerId = $ids['owner'];
}
$failure = TacticValidator::validateAccess($id, $tactic, $ownerId);
if ($failure != null) {
return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND);
}
return $this->openEditorFor($tactic);
}
}

@ -1,119 +0,0 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Http\HttpResponse;
class TacticController {
private TacticModel $tactics;
private ?TeamModel $teams;
public function __construct(TacticModel $tactics, ?TeamModel $teams = null) {
$this->tactics = $tactics;
$this->teams = $teams;
}
/**
* @param bool $toShare
* @param SessionHandle $session
* @return ViewHttpResponse
*/
public function displayTactic(bool $toShare, SessionHandle $session): ViewHttpResponse {
if($toShare) {
$results = $this->tactics->getAll($session->getAccount()->getUser()->getId());
} else {
$results = $this->tactics->getAllTacticSharedOwned($session->getAccount()->getUser()->getId());
}
return ViewHttpResponse::twig("display_tactic.html.twig", ['tactics' => $results, 'toShare' => $toShare]);
}
/**
* @param bool $toShare
* @param int $tacticId
* @param SessionHandle $session
* @return ViewHttpResponse
*/
public function displayTeamAndAccount(bool $toShare, int $tacticId, SessionHandle $session): ViewHttpResponse {
if($toShare) {
$results = $this->teams->getAllIsCoach($session->getAccount()->getUser()->getId());
} else {
$results = $this->teams->getAllIsCoach($session->getAccount()->getUser()->getId());
}
return ViewHttpResponse::twig("display_user_teams_accounts.html.twig", ['teams' => $results, 'tactic' => $tacticId, 'toShare' => $toShare]);
}
/**
* @param int $tacticId
* @param int $teamId
* @param SessionHandle $session
* @return ViewHttpResponse
*/
public function displayShareConfirmation(int $tacticId, int $teamId, SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::twig("display_share_confirmation.html.twig", ['team' => $teamId, 'tactic' => $tacticId]);
}
/**
* @param array<array<string, mixed>> $confirmation
* @param int $tacticId
* @param int $teamId
* @param SessionHandle $session
* @return HttpResponse
*/
public function shareTacticToTeam(array $confirmation, int $tacticId, int $teamId, SessionHandle $session): HttpResponse {
if($confirmation['confirmation'] == "yes") {
$this->teams->shareTacticToTeam($teamId, $tacticId);
}
return ViewHttpResponse::redirect("/");
}
/**
* @param array<string> $request
* @param int $tacticId
* @param SessionHandle $session
* @return HttpResponse
*/
public function shareTacticToAccount(array $request, int $tacticId, SessionHandle $session): HttpResponse {
$email = $request["email"];
$this->tactics->shareTacticToAccountMail($email, $tacticId);
return ViewHttpResponse::redirect("/");
}
/**
* @param int $tacticId
* @param SessionHandle $session
* @return HttpResponse
*/
public function unshareTactic(int $tacticId, SessionHandle $session): HttpResponse {
$this->tactics->unshareTactic($tacticId);
$this->teams->unshareTactic($tacticId);
return ViewHttpResponse::redirect("/");
}
/**
* @param int $tacticId
* @param int $teamId
* @param SessionHandle $session
* @return HttpResponse
*/
public function unshareTacticToTeam(int $tacticId, int $teamId, SessionHandle $session): HttpResponse {
$this->teams->unshareTacticToTeam($tacticId, $teamId);
return ViewHttpResponse::redirect("/");
}
/**
* @param int $tacticId
* @param int $accountId
* @param SessionHandle $session
* @return HttpResponse
*/
public function unshareTacticToAccount(int $tacticId, int $accountId, SessionHandle $session): HttpResponse {
$this->tactics->unshareTacticToAccount($tacticId, $accountId);
return ViewHttpResponse::redirect("/");
}
}

@ -1,255 +0,0 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Data\Account;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Validation\FieldValidationFail;
use IQBall\Core\Validation\ValidationFail;
use IQBall\Core\Validation\DefaultValidators;
class TeamController {
private TeamModel $model;
/**
* @param TeamModel $model
*/
public function __construct(TeamModel $model) {
$this->model = $model;
}
/**
* @param SessionHandle $session
* @return ViewHttpResponse the team creation panel
*/
public function displayCreateTeam(SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::twig("insert_team.html.twig", []);
}
/**
* @param SessionHandle $session
* @return ViewHttpResponse the team panel to delete a member
*/
public function displayDeleteMember(SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::twig("delete_member.html.twig", []);
}
/**
* create a new team from given request name, mainColor, secondColor and picture url
* @param array<string, mixed> $request
* @param SessionHandle $session
* @return HttpResponse
*/
public function submitTeam(array $request, SessionHandle $session): HttpResponse {
$failures = [];
$request = HttpRequest::from($request, $failures, [
"name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()],
"main_color" => [DefaultValidators::hexColor()],
"second_color" => [DefaultValidators::hexColor()],
"picture" => [DefaultValidators::isURL()],
]);
if (!empty($failures)) {
$badFields = [];
foreach ($failures as $e) {
if ($e instanceof FieldValidationFail) {
$badFields[] = $e->getFieldName();
}
}
return ViewHttpResponse::twig('insert_team.html.twig', ['bad_fields' => $badFields]);
}
$teamId = $this->model->createTeam($request['name'], $request['picture'], $request['main_color'], $request['second_color']);
$this->model->addMember($session->getAccount()->getUser()->getEmail(), $teamId, 'COACH');
return HttpResponse::redirect('/team/' . $teamId);
}
/**
* @param SessionHandle $session
* @return ViewHttpResponse the panel to search a team by its name
*/
public function displayListTeamByName(SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::twig("list_team_by_name.html.twig", []);
}
/**
* returns a view that contains all the teams description whose name matches the given name needle.
* @param array<string, mixed> $request
* @param SessionHandle $session
* @return HttpResponse
*/
public function listTeamByName(array $request, SessionHandle $session): HttpResponse {
$errors = [];
$request = HttpRequest::from($request, $errors, [
"name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()],
]);
if (!empty($errors) && $errors[0] instanceof FieldValidationFail) {
$badField = $errors[0]->getFieldName();
return ViewHttpResponse::twig('list_team_by_name.html.twig', ['bad_field' => $badField]);
}
$teams = $this->model->listByName($request['name'], $session->getAccount()->getUser()->getId());
if (empty($teams)) {
return ViewHttpResponse::twig('display_teams.html.twig', []);
}
return ViewHttpResponse::twig('display_teams.html.twig', ['teams' => $teams]);
}
/**
* Delete a team with its id
* @param int $id
* @param SessionHandle $session
* @return HttpResponse
*/
public function deleteTeamById(int $id, SessionHandle $session): HttpResponse {
$a = $session->getAccount();
$ret = $this->model->deleteTeam($a->getUser()->getEmail(), $id);
if($ret != 0) {
return ViewHttpResponse::twig('display_team.html.twig', ['notDeleted' => true]);
}
return HttpResponse::redirect('/');
}
/**
* Display a team with its id
* @param int $id
* @param SessionHandle $session
* @return ViewHttpResponse a view that displays given team information
*/
public function displayTeam(int $id, SessionHandle $session): ViewHttpResponse {
$result = $this->model->getTeam($id, $session->getAccount()->getUser()->getId());
if($result == null) {
return ViewHttpResponse::twig('error.html.twig', [
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette équipe.")],
], HttpCodes::FORBIDDEN);
}
$role = $this->model->isCoach($id, $session->getAccount()->getUser()->getEmail());
$tactics = $this->model->getAllTeamTactic($id);
return ViewHttpResponse::react(
'views/TeamPanel.tsx',
[
'team' => [
"info" => $result->getInfo(),
"members" => $result->listMembers(),
],
'isCoach' => $role,
'currentUserId' => $session->getAccount()->getUser()->getId(),
'tactics' => $tactics,
]
);
}
/**
* @param int $idTeam
* @param SessionHandle $session
* @return ViewHttpResponse the team panel to add a member
*/
public function displayAddMember(int $idTeam, SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::twig("add_member.html.twig", ['idTeam' => $idTeam]);
}
/**
* add a member to a team
* @param int $idTeam
* @param array<string, mixed> $request
* @param SessionHandle $session
* @return HttpResponse
*/
public function addMember(int $idTeam, array $request, SessionHandle $session): HttpResponse {
$errors = [];
if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) {
return ViewHttpResponse::twig('error.html.twig', [
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")],
], HttpCodes::FORBIDDEN);
}
$request = HttpRequest::from($request, $errors, [
"email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)],
]);
if(!empty($errors)) {
return ViewHttpResponse::twig('add_member.html.twig', ['badEmail' => true,'idTeam' => $idTeam]);
}
$ret = $this->model->addMember($request['email'], $idTeam, $request['role']);
switch($ret) {
case -1:
return ViewHttpResponse::twig('add_member.html.twig', ['notFound' => true,'idTeam' => $idTeam]);
case -2:
return ViewHttpResponse::twig('add_member.html.twig', ['alreadyExisting' => true,'idTeam' => $idTeam]);
default:
return HttpResponse::redirect('/team/' . $idTeam);
}
}
/**
* remove a member from a team with their ids
* @param int $idTeam
* @param int $idMember
* @param SessionHandle $session
* @return HttpResponse
*/
public function deleteMember(int $idTeam, int $idMember, SessionHandle $session): HttpResponse {
if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) {
return ViewHttpResponse::twig('error.html.twig', [
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")],
], HttpCodes::FORBIDDEN);
}
$teamId = $this->model->deleteMember($idMember, $idTeam);
if($teamId == -1 || $session->getAccount()->getUser()->getId() == $idMember) {
return HttpResponse::redirect('/');
}
return $this->displayTeam($teamId, $session);
}
/**
* @param int $idTeam
* @param SessionHandle $session
* @return ViewHttpResponse
*/
public function displayEditTeam(int $idTeam, SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::twig("edit_team.html.twig", ['team' => $this->model->getTeam($idTeam, $session->getAccount()->getUser()->getId())]);
}
/**
* @param int $idTeam
* @param array<string,mixed> $request
* @param SessionHandle $session
* @return HttpResponse
*/
public function editTeam(int $idTeam, array $request, SessionHandle $session): HttpResponse {
if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) {
return ViewHttpResponse::twig('error.html.twig', [
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")],
], HttpCodes::FORBIDDEN);
}
$failures = [];
$request = HttpRequest::from($request, $failures, [
"name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()],
"main_color" => [DefaultValidators::hexColor()],
"second_color" => [DefaultValidators::hexColor()],
"picture" => [DefaultValidators::isURL()],
]);
if (!empty($failures)) {
$badFields = [];
foreach ($failures as $e) {
if ($e instanceof FieldValidationFail) {
$badFields[] = $e->getFieldName();
}
}
return ViewHttpResponse::twig('edit_team.html.twig', ['bad_fields' => $badFields]);
}
$this->model->editTeam($idTeam, $request['name'], $request['picture'], $request['main_color'], $request['second_color']);
return HttpResponse::redirect('/team/' . $idTeam);
}
public function shareTactic(int $teamId, int $tacticId, SessionHandle $session): ViewHttpResponse {
$this->model->shareTacticToTeam($teamId, $tacticId);
return $this->displayTeam($teamId, $session);
}
}

@ -1,73 +0,0 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\MutableSessionHandle;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Model\TeamModel;
class UserController {
private TacticModel $tactics;
private ?TeamModel $teams;
/**
* @param TacticModel $tactics
* @param TeamModel|null $teams
*/
public function __construct(TacticModel $tactics, ?TeamModel $teams = null) {
$this->tactics = $tactics;
$this->teams = $teams;
}
/**
* @param SessionHandle $session
* @return ViewHttpResponse the home page view
*/
public function home(SessionHandle $session): ViewHttpResponse {
$limitNbTactics = 5;
$user = $session->getAccount()->getUser();
$lastTactics = $this->tactics->getLast($limitNbTactics, $user->getId());
$allTactics = $this->tactics->getAll($user->getId());
$allTacticsShared = $this->tactics->getAllTacticShared($user->getId());
if(isset($allTacticsShared)) {
foreach ($allTacticsShared as $tactic) {
if(!in_array($tactic, $allTactics)) {
array_push($allTactics, $tactic);
}
}
}
if ($this->teams != null) {
$teams = $this->teams->getAll($user->getId());
} else {
$teams = [];
}
return ViewHttpResponse::react("views/Home.tsx", [
"lastTactics" => $lastTactics,
"allTactics" => $allTactics,
"teams" => $teams,
"user" => $user,
]);
}
/**
* @return ViewHttpResponse account settings page
*/
public function settings(SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::react("views/Settings.tsx", []);
}
public function disconnect(MutableSessionHandle $session): HttpResponse {
$session->destroy();
return HttpResponse::redirect("/");
}
}

@ -1,39 +0,0 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\Validator\TacticValidator;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\TacticModel;
class VisualizerController {
private TacticModel $tacticModel;
/**
* @param TacticModel $tacticModel
*/
public function __construct(TacticModel $tacticModel) {
$this->tacticModel = $tacticModel;
}
/**
* Opens a visualisation page for the tactic specified by its identifier in the url.
* @param int $id
* @param SessionHandle $session
* @return HttpResponse
*/
public function openVisualizer(int $id, SessionHandle $session): HttpResponse {
$tactic = $this->tacticModel->get($id);
$failure = TacticValidator::validateAccess($id, $tactic, $session->getAccount()->getUser()->getId());
if ($failure != null) {
return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND);
}
return ViewHttpResponse::react("views/Visualizer.tsx", ["name" => $tactic->getName()]);
}
}

@ -1,22 +0,0 @@
<?php
namespace IQBall\App\Session;
use IQBall\Core\Data\Account;
/**
* The mutable side of a session handle
*/
interface MutableSessionHandle extends SessionHandle {
/**
* @param string|null $url the url to redirect the user to after authentication.
*/
public function setInitialTarget(?string $url): void;
/**
* @param Account $account update the session's account
*/
public function setAccount(Account $account): void;
public function destroy(): void;
}

@ -1,38 +0,0 @@
<?php
namespace IQBall\App\Session;
use IQBall\Core\Data\Account;
/**
* A PHP session handle
*/
class PhpSessionHandle implements MutableSessionHandle {
public static function init(): self {
if (session_status() !== PHP_SESSION_NONE) {
throw new \Exception("A php session is already started !");
}
session_start();
return new PhpSessionHandle();
}
public function getAccount(): ?Account {
return $_SESSION["account"] ?? null;
}
public function getInitialTarget(): ?string {
return $_SESSION["target"] ?? null;
}
public function setAccount(Account $account): void {
$_SESSION["account"] = $account;
}
public function setInitialTarget(?string $url): void {
$_SESSION["target"] = $url;
}
public function destroy(): void {
session_destroy();
}
}

@ -1,23 +0,0 @@
<?php
namespace IQBall\App\Session;
use IQBall\Core\Data\Account;
/**
* An immutable session handle
*/
interface SessionHandle {
/**
* The initial target url if the user wanted to perform an action that requires authentication
* but has been required to login first in the application.
* @return string|null Get the initial targeted URL
*/
public function getInitialTarget(): ?string;
/**
* The session account if the user is logged in.
* @return Account|null
*/
public function getAccount(): ?Account;
}

@ -1,18 +0,0 @@
<?php
namespace IQBall\App\Validator;
use IQBall\Core\Data\TacticInfo;
use IQBall\Core\Validation\ValidationFail;
class TacticValidator {
public static function validateAccess(int $tacticId, ?TacticInfo $tactic, int $ownerId): ?ValidationFail {
if ($tactic == null) {
return ValidationFail::notFound("La tactique $tacticId n'existe pas");
}
if ($tactic->getOwnerId() != $ownerId) {
return ValidationFail::unauthorized("Vous ne pouvez pas accéder à cette tactique.");
}
return null;
}
}

@ -1,75 +0,0 @@
<?php
namespace IQBall\App;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpResponse;
class ViewHttpResponse extends HttpResponse {
public const TWIG_VIEW = 0;
public const REACT_VIEW = 1;
/**
* @var string File path of the responded view
*/
private string $file;
/**
* @var array<string, mixed> View arguments
*/
private array $arguments;
/**
* @var int Kind of view, see {@link self::TWIG_VIEW} and {@link self::REACT_VIEW}
*/
private int $kind;
/**
* @param int $code
* @param int $kind
* @param string $file
* @param array<string, mixed> $arguments
*/
private function __construct(int $kind, string $file, array $arguments, int $code = HttpCodes::OK) {
parent::__construct($code, []);
$this->kind = $kind;
$this->file = $file;
$this->arguments = $arguments;
}
public function getViewKind(): int {
return $this->kind;
}
public function getFile(): string {
return $this->file;
}
/**
* @return array<string, string>
*/
public function getArguments(): array {
return $this->arguments;
}
/**
* Create a twig view response
* @param string $file
* @param array<string, mixed> $arguments
* @param int $code
* @return ViewHttpResponse
*/
public static function twig(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse {
return new ViewHttpResponse(self::TWIG_VIEW, $file, $arguments, $code);
}
/**
* Create a react view response
* @param string $file
* @param array<string, mixed> $arguments
* @param int $code
* @return ViewHttpResponse
*/
public static function react(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse {
return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code);
}
}

@ -1,23 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Paramètres</title>
<style>
body {
padding-left: 10%;
padding-right: 10%;
}
</style>
</head>
<body>
<button onclick="location.pathname='{{ path('/home') }}'">Retour</button>
<h1>Paramètres</h1>
</body>
</html>

@ -1,118 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Ajouter un membre</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"],
input[type="radio"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
fieldset {
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
.role {
margin-top: 10px;
}
.radio {
display: flex;
justify-content: space-between;
}
.failed{
color: red;
}
</style>
</head>
<body>
<header>
<h1><a href="{{ path('/') }}">IQBall</a></h1>
</header>
<div class="container">
<h2>Ajouter un membre à votre équipe</h2>
<form action="{{ path("/team/#{idTeam}/addMember") }}" method="POST">
<div class="form-group">
<label for="email">Email du membre :</label>
{% if badEmail %}
<p class="failed">Email invalide</p>
{% endif %}
{%if notFound %}
<p class="failed">Cette personne n'a pas été trouvé</p>
{% endif %}
{% if alreadyExisting %}
<p class="failed">Cette personne est déjà dans l'équipe</p>
{% endif %}
<input type="text" id="email" name="email" required>
<fieldset class="role">
<legend>Rôle du membre dans l'équipe :</legend>
<div class="radio">
<label for="P">Joueur</label>
<input type="radio" id="P" name="role" value="PLAYER" checked />
</div>
<div class="radio">
<label for="C">Coach</label>
<input type="radio" id="C" name="role" value="COACH" />
</div>
</fieldset>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Ajouter un membre</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<h2>Supprimez un membre de votre équipe</h2>
<form action="{{ path('/team/members/remove') }}" method="POST">
<div class="form-group">
<label for="team">Team où supprimer le membre :</label>
<input type="text" id="team" name="team" required>
<label for="mail">Email du membre :</label>
<input type="text" id="mail" name="mail" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,46 +0,0 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profil Utilisateur</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
align-items: start;
justify-content: center;
height: 100vh;
}
.user-profile {
background-color: #7FBFFF;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 100%;
text-align: center;
}
h1 {
color: #333;
}
p {
color: #666;
}
</style>
</head>
<body>
<div class="user-profile">
<h1>Votre profil</h1>
<p><strong>Pseudo : </strong> {{ username }} </p>
<p><strong>Email : {{ email }} </strong></p>
</div>
</body>
</html>

@ -1,107 +0,0 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Connexion</title>
</head>
<body>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
.error-messages {
color: #ff331a;
font-style: italic;
}
{% for err in fails %}
.form-group
#
{{ err.getFieldName() }}
{
border-color: red
;
}
{% endfor %}
.inscr {
font-size: small;
}
#buttons{
display: flex;
justify-content: center;
padding: 10px 20px;
}
.button{
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.button:hover{
background-color: #0056b3;
}
</style>
<div class="container">
<center><h2>Se connecter</h2></center>
<form action="{{ path('/login') }}" method="post">
<div class="form-group">
{% for name in fails %}
<label class="error-messages"> {{ name.getMessage() }} </label>
{% endfor %}
<label for="email">Email :</label>
<input type="text" id="email" name="email" required>
<label for="password">Mot de passe :</label>
<input type="password" id="password" name="password" required>
<a href="{{ path('/register') }}" class="inscr">Vous n'avez pas de compte ?</a>
<br><br>
<div id = "buttons">
<input class = "button" type="submit" value="Se connecter">
</div>
</form>
</div>
</body>
</html>

@ -1,123 +0,0 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>S'enregistrer</title>
</head>
<body>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
.error-messages {
color: #ff331a;
font-style: italic;
}
{% for err in fails %}
.form-group
#
{{ err.getFieldName() }}
{
border-color: red
;
}
{% endfor %}
.inscr{
font-size: small;
text-align: right;
}
.consentement{
font-size: small;
}
#buttons{
display: flex;
justify-content: center;
padding: 10px 20px;
}
.button{
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.button:hover{
background-color: #0056b3;
}
</style>
<div class="container">
<center><h2>S'enregistrer</h2></center>
<form action="{{ path('/register') }}" method="post">
<div class="form-group">
{% for name in fails %}
<label class="error-messages"> {{ name.getFieldName() }} : {{ name.getMessage() }} </label>
{% endfor %}
<label for="username">Nom d'utilisateur :</label>
<input type="text" id="username" name="username" value="{{ username }}"required>
<label for="password">Mot de passe :</label>
<input type="password" id="password" name="password" required>
<label for="confirmpassword">Confirmer le mot de passe :</label>
<input type="password" id="confirmpassword" name="confirmpassword" required>
<label for="email">Email :</label>
<input type="text" id="email" name="email" value="{{ email }}"required><br><br>
<label class="consentement">
<input type="checkbox" name="consentement" required>
En cochant cette case, j'accepte que mes données personnelles, tel que mon adresse e-mail, soient collectées et traitées conformément à la politique de confidentialité de Sportify.
</label><br>
<a href="{{ path('/login') }}" class="inscr">Vous avez déjà un compte ?</a>
</div>
<div id = "buttons">
<input class = "button" type="submit" value="Créer votre compte">
</div>
</form>
</div>
</body>
</html>

@ -1,18 +0,0 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Twig view</title>
</head>
<body>
<h1>Hello world</h1>
{% for v in results %}
<p>username: {{ v.name }}</p>
<p>description: {{ v.description }}</p>
{% endfor %}
</body>
</html>

@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Confirmation</title>
</head>
<body>
<div class="container">
<h2>Etes-vous sûr de vouloir partager la tactique ?</h2>
<form action="{{ path("/shareTactic/#{tactic}/team/#{team}") }}" method="POST">
<div>
<input type="radio" id="yes" name="confirmation" value="yes" />
<label for="yes">Oui</label>
</div>
<div>
<input type="radio" id="no" name="confirmation" value="no" />
<label for="no">Non</label>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tactiques partageables</title>
</head>
<body>
<header>
<h1><a href="{{ path('/') }}">IQBall</a></h1>
</header>
{% if toShare %}
{% for t in tactics %}
<div onclick="window.location.href = '{{ path("/shareTactic/#{t.id}") }}'">
<p> {{ t.name }} </p>
</div>
{% endfor %}
{% else %}
{% for t in tactics %}
<div onclick="window.location.href = '{{ path("/unshareTactic/#{t.id}") }}'">
<p> {{ t.name }} </p>
</div>
{% endfor %}
{% endif %}
</body>
</html>

@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Twig view</title>
<style>
body {
background-color: #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
}
.square {
width: 50px;
height: 50px;
}
#main_color {
border: solid;
background-color: {{ team.getInfo().getMainColor().getValue() }};
}
#second_color {
background-color: {{ team.getInfo().getSecondColor().getValue() }};
border: solid;
}
section {
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
width: 60%;
}
#colors{
flex-direction: row;
}
.color {
flex-direction: row;
justify-content: space-between;
}
.logo {
height: 80px;
width: 80px;
}
#delete{
border-radius:10px ;
background-color: red;
color: white;
}
.player{
flex-direction: row;
justify-content: space-evenly;
}
</style>
</head>
<body>
<header>
<h1><a href="{{ path('/') }}">IQBall</a></h1>
</header>
<section class="container">
{% if notDeleted %}
<popup>
<p>Cette équipe ne peut être supprimée.</p>
</popup>
{% endif %}
{% if team is defined %}
<div class="team">
<div>
<h1>{{ team.getInfo().getName() }}</h1>
<img src="{{ team.getInfo().getPicture() }}" alt="Logo d'équipe" class="logo">
</div>
<div id="colors">
<div class="color"><p>Couleur principale : </p>
<div class="square" id="main_color"></div>
</div>
<div class="color"><p>Couleur secondaire : </p>
<div class="square" id="second_color"></div>
</div>
</div>
{% if isCoach %}
<button id="delete" onclick="confirm('Êtes-vous sûr de supprimer cette équipe?') ? window.location.href = '{{ path("/team/#{team.getInfo().getId()}/delete") }}' : {}">Supprimer</button>
<button></button>
{% endif %}
{% for m in team.listMembers() %}
<div class="player">
<p> {{ m.getUserId() }} </p>
{% if m.getRole().isCoach() %}
<p> : Coach</p>
{% else %}
<p> : Joueur</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div>
<h3>Cette équipe ne peut être affichée</h3>
</div>
{% endif %}
</section>
</body>
</html>

@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Twig view</title>
<style>
body {
display: flex;
flex-direction: column;
background-color: #f1f1f1;
align-items: center;
}
section{
flex-direction: row;
justify-content: space-around;
background-color: white;
width: 60%;
}
.team {
border-radius: 10px;
border-color: darkgrey;
}
.logo_team {
width: 15%;
aspect-ratio: 3/2;
object-fit: contain;
}
</style>
</head>
<body>
<header>
<h1><a href="{{ path('/') }}">IQBall</a></h1>
</header>
<section>
{% if teams is empty %}
<p>Aucune équipe n'a été trouvée</p>
<div class="container">
<h2>Chercher une équipe</h2>
<form action="{{ path('/team/search') }}" method="post">
<div class="form-group">
<label for="name">Nom de l'équipe :</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
{% else %}
{% for t in teams %}
<div class="team" onclick="window.location.href = '{{ path("/team/#{t.getId()}") }}'">
<p>Nom de l'équipe : {{ t.getName() }}</p>
<img src="{{ t.getPicture() }}" alt="logo de l'équipe" class="logo_team">
</div>
{% endfor %}
{% endif %}
</section>
</body>
</html>

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Twig view</title>
</head>
<body>
{% if toShare %}
{% if teams is empty %}
<p>Vous n'êtes dans aucune équipe</p>
{% else %}
{% for team in teams %}
<div class="team" onclick="window.location.href = '{{ path("/shareTactic/#{tactic}/team/#{team.id}") }}'">
<p>Nom de l'équipe : {{ team.name }}</p>
<img src="{{ team.picture }}" alt="logo de l'équipe">
</div>
{% endfor %}
{% endif %}
<h2>Partager à un compte</h2>
<form action="{{ path("/shareTactic/#{tactic}/account") }}" method="POST">
<div class="form-group">
<label for="email">Email du membre :</label>
{% if badEmail %}
<p class="failed">Email invalide</p>
{% endif %}
{%if notFound %}
<p class="failed">Cette personne n'a pas été trouvé</p>
{% endif %}
<input type="text" id="email" name="email" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
{% else %}
<form action="{{ path("/unshareTactic/#{tactic}") }}" method="POST">
<button name="unshareAll" value="unshareAll">Supprimer le partage de cette tactique à tout le monde</button>
</form>
{% endif %}
</body>
</html>

@ -1,81 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Insertion view</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 5px auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
{% for item in bad_fields %}
#{{ item }}{
border-color: red;
}{% endfor %} input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<h2>Modifier votre équipe</h2>
<form action="{{ path('/team/' ~ team.getInfo().getId() ~ '/edit') }}" method="post">
<div class="form-group">
<label for="name">Nom de l'équipe :</label>
<input type="text" id="name" name="name" value="{{ team.getInfo().getName() }}" required>
<label for="picture">Logo:</label>
<input type="text" id="picture" name="picture" value="{{ team.getInfo().getPicture() }}" required>
<label for="main_color">Couleur principale</label>
<input type="color" value="{{ team.getInfo().getMainColor() }}" id="main_color" name="main_color" required>
<label for="second_color">Couleur secondaire</label>
<input type="color" id="second_color" name="second_color" value="{{ team.getInfo().getSecondColor() }}" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,57 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Error</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
h1 {
color: #da6110;
text-align: center;
margin-bottom: 15px
}
h2 {
text-align: center;
margin-bottom: 15px;
margin-top: 15px
}
.button {
display: block;
cursor: pointer;
background-color: white;
color: black;
text-align: center;
font-size: 20px;
border-radius: 12px;
border: 2px solid #da6110;
margin-top: 15px;
}
.button:hover {
background-color: #da6110
}
</style>
</head>
<body>
<h1>IQBall</h1>
{% for fail in failures %}
<h2>{{ fail.getKind() }} : {{ fail.getMessage() }}</h2>
{% endfor %}
<button class="button" onclick="location.href='{{ path('/home') }}'" type="button">Retour à la page d'accueil</button>
</body>
</html>

@ -1,97 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Page d'accueil</title>
<style>
body {
padding-left: 10%;
padding-right: 10%;
}
#bandeau {
display: flex;
flex-direction: row;
}
#bandeau > h1 {
self-align: center;
padding: 0%;
margin: 0%;
justify-content: center;
}
#account {
display: flex;
flex-direction: column;
align-content: center;
}
#account:hover {
background-color: gray;
}
#account img {
width: 70%;
height: auto;
align-self: center;
padding: 5%;
margin: 0%;
}
#account p {
align-self: center;
}
</style>
</head>
<body>
<button onclick="location.pathname='{{ path('/disconnect') }}'"> Se déconnecter</button>
<div id="bandeau">
<h1>IQ Ball</h1>
<div id="account" onclick="location.pathname='{{ path('/settings') }}'">
<img
src="{{ path('/assets/icon/account.svg') }}"
alt="Account logo"
/>
<p>Mon profil
<p>
</div>
</div>
<h2>Mes équipes</h2>
<button onclick="location.pathname='{{ path('/team/new') }}'"> Créer une nouvelle équipe</button>
{% if recentTeam != null %}
{% for team in recentTeam %}
<div>
<p> {{ team.name }} </p>
</div>
{% endfor %}
{% else %}
<p>Aucune équipe créée !</p>
{% endif %}
<h2> Mes strategies </h2>
<button onclick="location.pathname='{{ path('/tactic/new') }}'"> Créer une nouvelle tactique</button>
{% if recentTactic != null %}
{% for tactic in recentTactic %}
<div onclick="location.pathname='{{ path("/tactic/#{strategie.id}/edit") }}'">
<p> {{ tactic.id }} - {{ tactic.name }} - {{ tactic.creation_date }} </p>
<button onclick="location.pathname='{{ path("/tactic/#{tactic.id}/edit") }}'"> Editer la
stratégie {{ tactic.id }} </button>
</div>
{% endfor %}
{% else %}
<p> Aucune tactique créée !</p>
{% endif %}
</body>
</html>

@ -1,81 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Insertion view</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
{% for item in bad_fields %}
#{{ item }}{
border-color: red;
}{% endfor %} input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<h2>Créer une équipe</h2>
<form action="{{ path('/team/new') }}" method="post">
<div class="form-group">
<label for="name">Nom de l'équipe :</label>
<input type="text" id="name" name="name" required>
<label for="picture">Logo:</label>
<input type="text" id="picture" name="picture" required>
<label for="main_color">Couleur principale</label>
<input type="color" value="#ffffff" id="main_color" name="main_color" required>
<label for="second_color">Couleur secondaire</label>
<input type="color" id="second_color" name="second_color" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Insertion view</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
{% for item in bad_fields %}
#{{ item }}{
border-color: red;
}{% endfor %} input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<header>
<h1><a href="{{ path('/') }}">IQBall</a></h1>
</header>
<div class="container">
<h2>Chercher une équipe</h2>
<form action="{{ path('/team/search') }}" method="post">
<div class="form-group">
<label for="name">Nom de l'équipe :</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,59 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<script type="module">
<?php
if (SUPPORTS_FAST_REFRESH) {
$asset_server = asset("");
echo "
import RefreshRuntime from '{$asset_server}front/@react-refresh'
RefreshRuntime.injectIntoGlobalHook(window)
window.\$RefreshReg$ = () => {}
window.\$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
";
}
?>
</script>
<link rel="icon" href="<?= asset("assets/favicon.ico") ?>">
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<!-- remove default screen margin,
html and body to take full screen size -->
<style>
body, html, #root {
height: 100%;
width: 100%;
margin: 0;
overflow: visible;
}
</style>
</head>
<body>
<div id="root"></div>
<!--
here's the magic.
imports the given view URL, and assume that the view exports a function named `Component`.
see ViewRenderer.tsx::renderView for more info
-->
<script type="module">
import {renderView} from "<?= asset("ViewRenderer.tsx") ?>"
import Component from "<?= asset($url) ?>"
renderView(Component, <?= json_encode($arguments) ?>)
</script>
<script>
</script>
</body>
</html>

@ -1,13 +0,0 @@
<?php
/**
* sends a react view to the user client.
* @param string $url url of the react file to render
* @param array<string, mixed> $arguments arguments to pass to the rendered react component
* The arguments must be a json-encodable key/value dictionary.
* @return void
*/
function send_react_front(string $url, array $arguments) {
// the $url and $argument values are used into the included file
require_once "react-display-file.php";
}

@ -6,4 +6,7 @@ export const API = import.meta.env.VITE_API_ENDPOINT
/** /**
* This constant defines the base app's endpoint. * This constant defines the base app's endpoint.
*/ */
export const BASE = import.meta.env.VITE_BASE export const BASE = import.meta.env.BASE_URL.slice(
0,
import.meta.env.BASE_URL.length - 1,
)

@ -1,70 +0,0 @@
<?php
namespace IQBall\Core;
use IQBall\Core\Http\HttpResponse;
/**
* Represent an action.
* @template S session
*/
class Action {
public const NO_AUTH = 1;
public const AUTH_USER = 2;
public const AUTH_ADMIN = 3;
/**
* @var callable(mixed[], S): HttpResponse $action action to call
*/
protected $action;
private int $authType;
/**
* @param callable(mixed[], S): HttpResponse $action
*/
protected function __construct(callable $action, int $authType) {
$this->action = $action;
$this->authType = $authType;
}
public function getAuthType(): int {
return $this->authType;
}
/**
* Runs an action
* @param mixed[] $params
* @param S $session
* @return HttpResponse
*/
public function run(array $params, $session): HttpResponse {
$params = array_values($params);
$params[] = $session;
return call_user_func_array($this->action, $params);
}
/**
* @param callable(mixed[], S): HttpResponse $action
* @return Action<S> an action that does not require to have an authorization.
*/
public static function noAuth(callable $action): Action {
return new Action($action, self::NO_AUTH);
}
/**
* @param callable(mixed[], S): HttpResponse $action
* @return Action<S> an action that does require to have an authorization.
*/
public static function auth(callable $action): Action {
return new Action($action, self::AUTH_USER);
}
/**
* @param callable(mixed[], S): HttpResponse $action
* @return Action<S> an action that does require to have an authorization, and to be an administrator.
*/
public static function admin(callable $action): Action {
return new Action($action, self::AUTH_ADMIN);
}
}

@ -1,61 +0,0 @@
<?php
namespace IQBall\Core;
use PDO;
class Connection {
private PDO $pdo;
/**
* @param PDO $pdo
*/
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function lastInsertId(): string {
return $this->pdo->lastInsertId();
}
/**
* execute a request
* @param string $query
* @param array<string, array<mixed, int>> $args
* @return void
*/
public function exec(string $query, array $args) {
$stmnt = $this->prep($query, $args);
$stmnt->execute();
}
/**
* Execute a request, and return the returned rows
* @param string $query the SQL request
* @param array<string, array<mixed, int>> $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]`
* @return array<string, mixed>[] the returned rows of the request
*/
public function fetch(string $query, array $args): array {
$stmnt = $this->prep($query, $args);
$stmnt->execute();
return $stmnt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @param string $query
* @param array<string, array<mixed, int>> $args
* @return \PDOStatement
*/
private function prep(string $query, array $args): \PDOStatement {
$stmnt = $this->pdo->prepare($query);
foreach ($args as $name => $value) {
$stmnt->bindValue($name, $value[0], $value[1]);
}
return $stmnt;
}
public function prepare(string $query): \PDOStatement {
return $this->pdo->prepare($query);
}
}

@ -1,53 +0,0 @@
<?php
namespace IQBall\Core;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Validation\ValidationFail;
use IQBall\Core\Validation\Validator;
class Control {
/**
* Runs given callback, if the request's payload json validates the given schema.
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
* The callback must accept an HttpRequest, and return an HttpResponse object.
* @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema
* @return HttpResponse
*/
public static function runChecked(array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): HttpResponse {
$request_body = file_get_contents('php://input');
$payload_obj = json_decode($request_body);
if (!$payload_obj instanceof \stdClass) {
$fail = new ValidationFail("bad-payload", "request body is not a valid json object");
return $errorFactory->apply([$fail]);
}
$payload = get_object_vars($payload_obj);
return self::runCheckedFrom($payload, $schema, $run, $errorFactory);
}
/**
* Runs given callback, if the given request data array validates the given schema.
* @param array<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
* The callback must accept an HttpRequest, and return an HttpResponse object.
* @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema
* @return HttpResponse
*/
public static function runCheckedFrom(array $data, array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): HttpResponse {
$fails = [];
$request = HttpRequest::from($data, $fails, $schema);
if (!empty($fails)) {
return $errorFactory->apply($fails);
}
return call_user_func_array($run, [$request]);
}
}

@ -1,14 +0,0 @@
<?php
namespace IQBall\Core;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Validation\ValidationFail;
interface ControlSchemaErrorResponseFactory {
/**
* @param ValidationFail[] $failures
* @return HttpResponse
*/
public function apply(array $failures): HttpResponse;
}

@ -1,41 +0,0 @@
<?php
namespace IQBall\Core\Data;
/**
* Base class of a user account.
* Contains the private information that we don't want
* to share to other users, or non-needed public information
*/
class Account {
/**
* @var string string token
*/
private string $token;
/**
* @var User contains all the account's "public" information
*/
private User $user;
/**
* @param string $token
* @param User $user
*/
public function __construct(string $token, User $user) {
$this->token = $token;
$this->user = $user;
}
public function getToken(): string {
return $this->token;
}
/**
* @return User
*/
public function getUser(): User {
return $this->user;
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save