Compare commits

..

No commits in common. 'b3490bd9c2999002e3d7db715f80a991cafc3be1' and 'c87c86f7a6635df1e393235933aa213dab64717c' have entirely different histories.

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

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

61
.gitignore vendored

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

@ -0,0 +1,16 @@
<?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,27 +4,25 @@
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
types de validation, nous devions quand même explicitement vérifier la présence des champs utilisés dans la requête. types de validation, nous devions quand même explicitement vérifier la présence des champs utilisés dans la requête.
```php ```php
public function doPostAction(array $form) { public function doPostAction(array $form) {
@ -41,11 +39,11 @@ public function doPostAction(array $form) {
if (Validation::isLenBetween($email, 6, 64))) { if (Validation::isLenBetween($email, 6, 64))) {
$failures[] = "L'adresse email doit être d'une longueur comprise entre 6 et 64 charactères."; $failures[] = "L'adresse email doit être d'une longueur comprise entre 6 et 64 charactères.";
} }
if (!empty($failures)) { if (!empty($failures)) {
return ViewHttpResponse::twig('error.html.twig', ['failures' => $failures]); return ViewHttpResponse::twig('error.html.twig', ['failures' => $failures]);
} }
// traitement ... // traitement ...
} }
``` ```
@ -58,29 +56,27 @@ 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 :
```php ```php
public function doPostAction(array $form): HttpResponse { public function doPostAction(array $form): HttpResponse {
$failures = []; $failures = [];
$req = HttpRequest::from($form, $failures, [ $req = HttpRequest::from($form, $failures, [
'email' => [DefaultValidators::email(), DefaultValidators::isLenBetween(6, 64)] 'email' => [Validators::email(), Validators::isLenBetween(6, 64)]
]); ]);
if (!empty($failures)) { //ou $req == null if (!empty($failures)) { //ou $req == null
return ViewHttpResponse::twig('error.html.twig', ['failures' => $failures]) return ViewHttpResponse::twig('error.html.twig', ['failures' => $failures])
} }
// 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
champs que celle-ci contient. champs que celle-ci contient.
Nous pouvons ensuite emballer les erreurs de validation dans des `ValidationFail` et `FieldValidationFail`, ce qui permet ensuite d'obtenir Nous pouvons ensuite emballer les erreurs de validation dans des `ValidationFail` et `FieldValidationFail`, ce qui permet ensuite d'obtenir
@ -88,41 +84,40 @@ 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.
`src/App` définit une `ViewHttpResponse`, qui permet aux controlleurs de retourner la vue qu'ils ont choisit. `src/App` définit une `ViewHttpResponse`, qui permet aux controlleurs de retourner la vue qu'ils ont choisit.
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.
L'index définit aussi quoi faire lorsque l'application retourne une réponse. Dans les implémentations actuelles, elle délègue simplement L'index définit aussi quoi faire lorsque l'application retourne une réponse. Dans les implémentations actuelles, elle délègue simplement
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
Notre application est une application de création et de visualisation de stratégies pour des match de basket. Notre application est une application de création et de visualisation de stratégies pour des match de basket.
Léditeur devra comporter un terrain sur lequel il est possible de placer et bouger des pions, représentant les joueurs. Léditeur devra comporter un terrain sur lequel il est possible de placer et bouger des pions, représentant les joueurs.
Une stratégie est un arbre composé de plusieurs étapes, une étape étant constituée dun ensemble de joueurs et dadversaires sur le terrain, Une stratégie est un arbre composé de plusieurs étapes, une étape étant constituée dun ensemble de joueurs et dadversaires sur le terrain,
aillant leur position de départ, et leur position cible, avec des flèches représentant le type de mouvement (dribble, écran, etc) effectué. aillant leur position de départ, et leur position cible, avec des flèches représentant le type de mouvement (dribble, écran, etc) effectué.
les enfants dune étape, sont dautres étapes en fonction des cas de figures (si tel joueur fait tel mouvement, ou si tel joueur fait telle passe etc). les enfants dune étape, sont dautres étapes en fonction des cas de figures (si tel joueur fait tel mouvement, ou si tel joueur fait telle passe etc).
Pour rendre le tout agréable à utiliser, il faut que linterface soit réactive : si lon bouge un joueur, Pour rendre le tout agréable à utiliser, il faut que linterface soit réactive : si lon bouge un joueur,
il faut que les flèches qui y sont liés bougent aussi, il faut que les joueurs puissent bouger le long des flèches en mode visualiseur etc… il faut que les flèches qui y sont liés bougent aussi, il faut que les joueurs puissent bouger le long des flèches en mode visualiseur etc…
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,90 +0,0 @@
# Welcome on the documentation's description
## Let's get started with the architecture diagram.
![architecture diagram](./assets/architecture.svg)
As you can see our entire application is build around three main package.
All of them contained in "src" package.
The core represent the main code of the web application.
It contains all the validation protocol, detailed below, the model of the imposed MVC architecture.
It also has a package named "data", it is a package of the structure of all the data we use in our application.
Of course there is package containing all the gateways as its name indicates. It is where we use the connection to our database.
Allowing to operate on it.
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.
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.
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.
Such as assets having all the image and stuff, model containing all the data's structure, style centralizing all css file and eventually components the last package used for the editor.
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.
![Class diagram](./assets/models.svg)
You can see how our data is structured contained in the package "data" as explained right above.
There is two clear part.
First of all, the Tactic one.
We got a nice class named TacticInfo representing as it says the information about a tactic, nothing to discuss more about.
It associates an attribute of type "CourtType". This last is just an "evoluated" type of enum with some more features.
We had to do it this way because of the language PHP that doesn't implement such a thing as an enum.
Now, let's discuss a much bigger part of the diagram.
In this part we find all the team logic. Actually, a team only have an array of members and a "TeamInfo".
The class "TeamInfo" only exists to split the team's information data (name, id etc) from the members.
The type Team does only link the information about a team and its members.
Talking about them, their class indicate what role they have (either Coach or Player) in the team.
Because a member is registered in the app, therefore he is a user of it. Represented by the type of the same name.
This class does only contain all the user's basic information.
The last class we have is the Account. It could directly be incorporated in User but we decided to split it the same way we did for the team.
Then, Account only has a user and a token which is an identifier.
## Validation's class diagram
![validation's class diagram](./assets/validation.svg)
We implemented our own validation system, here it is!
For the validation methods (for instance those in DefaultValidators) we use lambda to instantiate a Validator.
In general, we use the implementation "SimpleFunctionValidator".
We reconize the strategy pattern. Indeed, we need a family of algorithms because we have many classes that only differ by the way they validate.
Futhermore, you may have notices the ComposedValidator that allows to chain several Validator.
We can see that this system uses the composite pattern
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.
## Http's class diagram
![Http's class diagram](./assets/http.svg)
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.
We have two successors for now. ViewHttpResponse render not only a code but also a view, either react or twig ones.
Finally, we have the JsonHttpResponse that renders, as it's name says, some Json.
## Session's class diagram
![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.
## Model View Controller
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.
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.
### Team
![team's mvc](./assets/team.svg)
### Editor
![editor's mvc](./assets/editor.svg)
### Authentification
![auth's mvc](./assets/auth.svg)

@ -1,3 +1,3 @@
- [Description.md](Description.md) # The wiki also exists
- [Conception.md](Conception.md)
- [how-to-dev.md](how-to-dev.md) Some of our explanation are contained in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki)

@ -1,60 +0,0 @@
@startuml
'https://plantuml.com/component-diagram
package front{
package assets
package components
package model
package style
package views
}
database sql{
}
package src {
package "Api"{
}
package "App" {
package Controller
package Session
package Views
}
package Core{
package Data
package Gateway
package Http
package Model
package Validation
[Connection]
}
}
[sql] -- [Connection]
[views] -- [style]
[views] -- [components]
[views] -- [assets]
[views] -- [model]
[Gateway] -- [Connection]
[Validation] -- [Controller]
[Controller] -- [Session]
[Controller] -- [Http]
[Controller] -- [Views]
[Controller] -- [views]
[Controller] -- [Model]
[Model] -- [Gateway]
[Api] -- [Validation]
[Api] -- [Model]
[Api] -- [Http]
@enduml

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

@ -5,9 +5,9 @@ object Account {
name name
age age
email email
phone_number phoneNumber
password_hash passwordHash
profile_picture profilePicture
} }
object TacticFolder { object TacticFolder {

@ -1,21 +1,19 @@
# 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:
- Go to configuration > add new configuration 1) Use phpstorm to run a local php server:
- Select "PHP Built-in Web Server", then enter options as follow: * Go to configuration > add new configuration
![](assets/php-server-config.png) - port 8080 - name the configuration "RunServer" to be more explicit - place the "Document Root" in `/public` - host is localhost * Select "PHP Built-in Web Server", then enter options as follow:
- Click apply, OK ![](assets/php-server-config.png)
- Now run it. - port 8080
- 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.
@ -41,14 +39,12 @@ 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 {
@ -67,7 +63,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
<!-- <!--
@ -76,37 +72,35 @@ 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>
``` ```
here's how it renders if you do a request to `http://localhost:8080/`. here's how it renders if you do a request to `http://localhost:8080/`.
![](assets/render-react-php-file-processed.png) ![](assets/render-react-php-file-processed.png)
The index.php's router says that for a `GET` on the `/` url, we call the `SampleFormController#displayForm` method. The index.php's router says that for a `GET` on the `/` url, we call the `SampleFormController#displayForm` method.
This method then uses the `send_react_front`, to render the `views/SampleForm.tsx` react element, with no arguments (an empty array). This method then uses the `send_react_front`, to render the `views/SampleForm.tsx` react element, with no arguments (an empty array).
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).
Remember that react components and typescript files needs to be transpiled to javascript before being executable by a browser. Remember that react components and typescript files needs to be transpiled to javascript before being executable by a browser.
The generated file no longer requests the view to a `localhost:5173` or a `maxou.dev:5173` server, The generated file no longer requests the view to a `localhost:5173` or a `maxou.dev:5173` server,
now our react components are directly served by the same server, as they have been pre-compiled by our CI (see `/ci/.drone.yml` and `/ci/build_react.msh`) into valid js files that can directly be send to the browser. now our react components are directly served by the same server, as they have been pre-compiled by our CI (see `/ci/.drone.yml` and `/ci/build_react.msh`) into valid js files that can directly be send to the browser.
If you go back to our `index.php` file, you'll see that it requires a `../config.php` file, if you open it, If you go back to our `index.php` file, you'll see that it requires a `../config.php` file, if you open it,
you'll see that it defines the `asset(string $uri)` function that is used by the `src/react-display-file.php`, you'll see that it defines the `asset(string $uri)` function that is used by the `src/react-display-file.php`,
in the `<script>` block we talked earlier. in the `<script>` block we talked earlier.
By default, the `/config.php` file uses the `dev-config-profile.php` profile, 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
@ -118,13 +112,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 :
```php ```php
const ASSETS = [ const ASSETS = [
@ -134,9 +126,7 @@ 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";
@ -147,4 +137,4 @@ function _asset(string $assetURI): string {
// fallback to the uri itself. // fallback to the uri itself.
return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI); return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI);
} }
``` ```

@ -15,51 +15,37 @@ class HttpRequest implements ArrayAccess {
class HttpResponse { class HttpResponse {
- code: int - code: int
- headers : array + __construct(code: int)
+ __construct(code: int,headers:array)
+ getCode(): int + getCode(): int
+ getHeaders(): array
+ redirect(url:string, code:int): HttpResponse {static} <u>fromCode(code: int): HttpResponse
+ fromCode(code: int): HttpResponse {static}
} }
class JsonHttpResponse extends HttpResponse { class JsonHttpResponse extends HttpResponse {
- payload: mixed - payload: mixed
+ __construct(payload: mixed, code: int) + __construct(payload: mixed, code: int = HttpCodes::OK)
+ getJson(): string + getJson(): string
} }
class ViewHttpResponse extends HttpResponse { class ViewHttpResponse extends HttpResponse {
+ TWIG_VIEW: int {frozen} {static} + <u>TWIG_VIEW: int {frozen}
+ REACT_VIEW: int {frozen} {static} + <u>REACT_VIEW: int {frozen}
- file: string - file: string
- arguments: array - arguments: array
- kind: int - kind: int
- __construct(kind: int, file: string, arguments: array, code: int) - __construct(kind: int, file: string, arguments: array, code: int = HttpCodes::OK)
+ getViewKind(): int + getViewKind(): int
+ getFile(): string + getFile(): string
+ getArguments(): array + getArguments(): array
+ <u>twig(file: string, arguments: array, code: int): ViewHttpResponse + <u>twig(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse
+ <u>react(file: string, arguments: array, code: int): ViewHttpResponse + <u>react(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse
} }
note right of ViewHttpResponse note right of ViewHttpResponse
Into src/App Into src/App
end note end note
class HttpCodes{
+ OK : int {static} {frozen}
+ FOUND : int {static} {frozen}
+ BAD_REQUEST : int {static} {frozen}
+ UNAUTHORIZED : int {static} {frozen}
+ FORBIDDEN : int {static} {frozen}
+ NOT_FOUND : int {static} {frozen}
}
HttpCodes <.. ViewHttpResponse
HttpCodes <.. HttpResponse
@enduml @enduml

@ -6,93 +6,70 @@ class TacticInfo {
- creationDate: string - creationDate: string
- ownerId: string - ownerId: string
- content: string - content: string
+ __construct(id:int,name:string,creationDate:int,ownerId:int,courtType:CourtType,content:string)
+ getId(): int + getId(): int
+ getOwnerId(): int + getOwnerId(): int
+ getCreationTimestamp(): int + getCreationTimestamp(): int
+ getName(): string + getName(): string
+ getContent(): string + getContent(): string
+ getCourtType() : CourtType
} }
TacticInfo -->"- courtType" CourtType
class CourtType{
- value : int
- COURT_PLAIN : int {static} {frozen}
- COURT_HALF : int {static} {frozen}
- __construct(val:int)
+ plain() : CourtType {static}
+ half() : CourtType {static}
+ name() : string
+ fromName(name:string) : CourtType
+ isPlain() : bool
+ isHalf() : bool
}
note bottom: Basically an evoluated enum
class Account { class Account {
- email: string
- token: string - token: string
- name: string
- id: int
+ __construct(token:string,user:User) + getMailAddress(): string
+ getUser() : User + getToken(): string
+ getToken() : string + getName(): string
+ getId(): int
} }
Account -->"- user" User
class Member { class Member {
- userId: int
- teamId: int - teamId: int
- role : string
+ __construct(role : string) + __construct(role : MemberRole)
+ getUser(): User + getUserId(): int
+ getTeamId(): int + getTeamId(): int
+ getRole(): string + getRole(): MemberRole
} }
note bottom: Member's role is either "Coach" or "Player" Member --> "- role" MemberRole
enum MemberRole {
PLAYER
COACH
}
Member -->"- user" User
class TeamInfo { class TeamInfo {
- creationDate: int
- name: string - name: string
- picture: string - picture: string
- mainColor : string
- secondColor : string
+ __construct(id:int,name:string,picture:string,mainColor:string,secondColor:string)
+ getName(): string + getName(): string
+ getPicture(): string + getPicture(): string
+ getMainColor(): string + getMainColor(): Color
+ getSecondColor(): string + getSecondColor(): Color
} }
note left: Both team's color are the hex code of the color TeamInfo --> "- mainColor" Color
TeamInfo --> "- secondaryColor" Color
class Team { class Team {
+ __construct(info:TeamInfo,members: Member[]) getInfo(): TeamInfo
+ getInfo(): TeamInfo listMembers(): Member[]
+ listMembers(): Member[]
} }
Team --> "- info" TeamInfo Team --> "- info" TeamInfo
Team --> "- members *" Member Team --> "- members *" Member
class User{ class Color {
- id : int - value: int
- name : string
- email : string + getValue(): int
- profilePicture : string
+ __construct(id : int,name : string,email: string,profilePicture:string)
+ getId() : id
+ getName() : string
+ getEmail() : string
+ getProfilePicture() : string
} }
@enduml @enduml

@ -7,26 +7,26 @@ class AuthController {
+ displayLogin() : HttpResponse + displayLogin() : HttpResponse
+ login(request : array , session : MutableSessionHandle) : HttpResponse + login(request : array , session : MutableSessionHandle) : HttpResponse
} }
AuthController *-- "- model" AuthModel AuthController --> "- model" AuthModel
class AuthModel { class AuthModel {
+ __construct(gateway : AccountGateway) +__construct(gateway : AccountGateway)
+ register(username:string, password:string, confirmPassword:string, email:string, &failures:array): ?Account + generateToken() : string + register(username : string, password : string, confirmPassword : string, email : string, failures : array): Account
+ generateToken(): string + generateToken() : string
+ login(email:string, password:string, &failures:array): ?Account + login(email : string, password : string)
} }
AuthModel *-- "- gateway" AccountGateway AuthModel --> "- gateway" AccountGateway
class AccountGateway{ class AccountGateway {
+ __construct(con : Connexion) -con : Connection
+ insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): int +__construct(con : Connection)
+ getRowsFromMail(email:string): ?array + insertAccount(name : string, email : string, hash : string, token : string) : int
+ getHash(email:string): ?string + getRowsFromMail(email : string): array
+ exists(email:string): bool + getHash(email : string) : array
+ getAccountFromMail(email:string): ?Account + exists(email : string) : bool
+ getAccountFromToken(token:string): ?Account + getAccountFromMail(email : string ): Account
} + getAccountFromToken(email : string ): Account
AccountGateway *--"- con" Connexion }
@enduml @enduml

@ -1,44 +0,0 @@
@startuml
class EditorController {
+__construct (model : TacticModel)
+ openEditorFor(tactic:TacticInfo): ViewHttpResponse
+ createNew(): ViewHttpResponse
+ openTestEditor(courtType:CourtType): ViewHttpResponse
+ createNewOfKind(type:CourtType, session:SessionHandle): ViewHttpResponse
+ openEditor(id:int, session:SessionHandle): ViewHttpResponse
}
EditorController *-- "- model" TacticModel
class TacticModel {
+ TACTIC_DEFAULT_NAME:int {static}{frozen}
+ __construct(tactics : TacticInfoGateway)
+ makeNew(name:string, ownerId:int, type:CourtType): TacticInfo
+ makeNewDefault(ownerId:int, type:CourtType): ?TacticInfo
+ get(id:int): ?TacticInfo
+ getLast(nb:int, ownerId:int): array
+ getAll(ownerId:int): ?array
+ updateName(id:int, name:string, authId:int): array
+ updateContent(id:int, json:string): ?ValidationFail
}
TacticModel *-- "- tactics" TacticInfoGateway
class TacticInfoGateway{
+ __construct(con : Connexion)
+ get(id:int): ?TacticInfo
+ getLast(nb:int, ownerId:int): ?array
+ getAll(ownerId:int): ?array
+ insert(name:string, owner:int, type:CourtType): int
+ updateName(id:int, name:string): bool
+ updateContent(id:int, json:string): bool
}
TacticInfoGateway *--"- con" Connexion
class TacticValidator{
+ validateAccess(tacticId:int, tactic:?TacticInfo, ownerId:int): ?ValidationFail {static}
}
EditorController ..> TacticValidator
@enduml

@ -1,87 +1,63 @@
@startuml @startuml
class Team {
- name: string
- picture: Url
- members: array<int, MemberRole>
+ __construct(name : string, picture : string, mainColor : Colo, secondColor : Color)
+ getName(): string
+ getPicture(): Url
+ getMainColor(): Color
+ getSecondColor(): Color
+ listMembers(): array<Member>
}
Team --> "- mainColor" Color
Team --> "- secondColor" Color
class Color {
- value: string
- __construct(value : string)
+ getValue(): string
+ from(value: string): Color
+ tryFrom(value : string) : ?Color
}
class TeamGateway{ class TeamGateway{
--
+ __construct(con : Connexion) + __construct(con : Connexion)
+ insert(name : string ,picture : string, mainColor : Color, secondColor : Color) + insert(name : string ,picture : string, mainColor : Color, secondColor : Color)
+ listByName(name : string): array + listByName(name : string): array
+ getTeamById(id:int): ?TeamInfo
+ getTeamIdByName(name:string): ?int
+ deleteTeam(idTeam:int): void
+ editTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string)
+ getAll(user:int): array
} }
TeamGateway *--"- con" Connexion TeamGateway *--"- con" Connexion
TeamGateway ..> Color
class MemberGateway{
+ __construct(con : Connexion)
+ insert(idTeam:int, userId:int, role:string): void
+ getMembersOfTeam(teamId:int): array
+ remove(idTeam:int, idMember:int): void
+ isCoach(email:string, idTeam:int): bool
+ isMemberOfTeam(idTeam:int, idCurrentUser:int): bool
}
MemberGateway *--"- con" Connexion
class AccountGateway{
+ __construct(con : Connexion)
+ insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): int
+ getRowsFromMail(email:string): ?array
+ getHash(email:string): ?string
+ exists(email:string): bool
+ getAccountFromMail(email:string): ?Account
+ getAccountFromToken(token:string): ?Account
}
AccountGateway *--"- con" Connexion
class TeamModel{ class TeamModel{
---
+ __construct(gateway : TeamGateway) + __construct(gateway : TeamGateway)
+ createTeam(name : string,picture : string, mainColorValue : int, secondColorValue : int, errors : array) + createTeam(name : string,picture : string, mainColorValue : int, secondColorValue : int, errors : array)
+ addMember(mail:string, teamId:int, role:string): int
+ listByName(name : string ,errors : array) : ?array + listByName(name : string ,errors : array) : ?array
+ getTeam(idTeam:int, idCurrentUser:int): ?Team + displayTeam(id : int): Team
+ deleteMember(idMember:int, teamId:int): int
+ deleteTeam(email:string, idTeam:int): int
+ isCoach(idTeam:int, email:string): bool
+ editTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string)
+ getAll(user:int): array
} }
TeamModel *--"- members" MemberGateway TeamModel *--"- gateway" TeamGateway
TeamModel *--"- teams" TeamGateway TeamModel ..> Team
TeamModel *--"- teams" AccountGateway TeamModel ..> Color
class TeamController{ class TeamController{
+ __construct( model : TeamModel) - twig : Environement
+ displayCreateTeam(session:SessionHandle): ViewHttpResponse --
+ displayDeleteMember(session:SessionHandle): ViewHttpResponse + __construct( model : TeamModel, twig : Environement)
+ submitTeam(request:array, session:SessionHandle): HttpResponse + displaySubmitTeam() : HttpResponse
+ displayListTeamByName(session:SessionHandle): ViewHttpResponse + submitTeam(request : array) : HttpResponse
+ listTeamByName(request:array, session:SessionHandle): HttpResponse + displayListTeamByName(): HttpResponse
+ deleteTeamById(id:int, session:SessionHandle): HttpResponse + listTeamByName(request : array) : HttpResponse
+ displayTeam(id:int, session:SessionHandle): ViewHttpResponse + displayTeam(id : int): HttpResponse
+ displayAddMember(idTeam:int, session:SessionHandle): ViewHttpResponse
+ addMember(idTeam:int, request:array, session:SessionHandle): HttpResponse
+ deleteMember(idTeam:int, idMember:int, session:SessionHandle): HttpResponse
+ displayEditTeam(idTeam:int, session:SessionHandle): ViewHttpResponse
+ editTeam(idTeam:int, request:array, session:SessionHandle): HttpResponse
} }
TeamController *--"- model" TeamModel TeamController *--"- model" TeamModel
class Connexion { } class Connexion { }
@enduml @enduml

@ -1,27 +0,0 @@
@startuml
interface SessionHandle{
+ getInitialTarget(): ?string {abstract}
+ getAccount(): ?Account {abstract}
}
interface MutableSessionHandle{
+ setInitialTarget(url:?string): void
+ setAccount(account:Account): void
+ destroy(): void
}
class PhpSessionHandle{
+ init(): self {static}
+ getAccount(): ?Account
+ getInitialTarget(): ?string
+ setAccount(account:Account): void
+ setInitialTarget(url:?string): void
+ destroy(): void
}
PhpSessionHandle ..|> MutableSessionHandle
MutableSessionHandle ..|> SessionHandle
@enduml

@ -1,20 +1,18 @@
@startuml @startuml
abstract class Validator { abstract class Validator {
+ validate(name: string, val: mixed): array {abstract} + validate(name: string, val: mixed): array
+ then(other: Validator): Validator + then(other: Validator): Validator
} }
class ComposedValidator extends Validator { class ComposedValidator extends Validator {
- first: Validator
- then: Validator
+ __construct(first: Validator, then: Validator) + __construct(first: Validator, then: Validator)
+ validate(name: string, val: mixed): array validate(name: string, val: mixed): array
} }
ComposedValidator -->"- first" Validator
ComposedValidator -->"- then" Validator
class SimpleFunctionValidator extends Validator { class SimpleFunctionValidator extends Validator {
- predicate: callable - predicate: callable
- error_factory: callable - error_factory: callable
@ -30,9 +28,9 @@ class ValidationFail implements JsonSerialize {
+ __construct(kind: string, message: string) + __construct(kind: string, message: string)
+ getMessage(): string + getMessage(): string
+ getKind(): string + getKind(): string
+ jsonSerialize()
+ <u>notFound(message: string): ValidationFail + <u>notFound(message: string): ValidationFail
+ <u>unauthorized(message:string): ValidationFail
+ <u>error(message:string): ValidationFail
} }
class FieldValidationFail extends ValidationFail { class FieldValidationFail extends ValidationFail {
@ -51,31 +49,13 @@ class Validation {
<u> + validate(val: mixed, valName: string, failures: &array, validators: Validator...): bool <u> + validate(val: mixed, valName: string, failures: &array, validators: Validator...): bool
} }
Validation ..> Validator class Validators {
---
class DefaultValidators {
+ <u>nonEmpty(): Validator + <u>nonEmpty(): Validator
+ <u>shorterThan(limit: int): Validator + <u>shorterThan(limit: int): Validator
+ <u>userString(maxLen: int): Validator + <u>userString(maxLen: int): Validator
+ <u>regex(regex:string, msg:string): Validator ...
+ <u>hex(msg:string): Validator
+ <u>name(msg:string): Validator
+ <u>nameWithSpaces(): Validator
+ <u>lenBetween(min:int, max:int): Validator
+ <u>email(msg:string): Validator
+ <u>isInteger(): Validator
+ <u>isIntInRange(min:int, max:int): Validator
+ <u>isURL(): Validator
}
DefaultValidators ..> Validator
class FunctionValidator{
- validate_fn: callable
+ __construct(validate_fn:callable)
+ validate(name:string, val:mixed): array
} }
Validator <|-- FunctionValidator
@enduml @enduml

@ -1,8 +1,7 @@
# 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).

@ -4,41 +4,63 @@ 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: composer:latest
name: "php CI"
commands:
- composer install
- vendor/bin/phpstan analyze
- image: node:latest - image: node:latest
name: "build react" name: "build node"
volumes: &outputs volumes: &outputs
- name: server - name: server
path: /outputs path: /outputs
depends_on: depends_on:
- "front CI" - "front CI"
commands: commands:
- # force to use the backend master branch if pushing on master - curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh
- echo "VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-$([ "$DRONE_BRANCH" = master ] && echo master || cat .stage-backend-branch | tr / _)" > .env.STAGE - chmod +x /tmp/moshell_setup.sh
- npm run build -- --base=/$DRONE_BRANCH/ --mode STAGE - echo n | /tmp/moshell_setup.sh
- mv dist/* /outputs - echo "VITE_API_ENDPOINT=/IQBall/$DRONE_BRANCH/public/api" >> .env.PROD
- echo "VITE_BASE=/IQBall/$DRONE_BRANCH/public" >> .env.PROD
-
- /root/.local/bin/moshell ci/build_react.msh
- 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 -iE 's/\\/\\*PROFILE_FILE\\*\\/\\s*".*"/"profiles\\/prod-config-profile.php"/' config.php
- rm profiles/dev-config-profile.php
- mv src config.php sql profiles vendor /outputs/
- image: eeacms/rsync:latest - image: eeacms/rsync:latest
name: Deliver on staging server branch name: Deliver on staging server branch
depends_on: depends_on:
- "build react" - "prepare php"
- "build node"
volumes: *outputs volumes: *outputs
environment: environment:
SERVER_PRIVATE_KEY: SERVER_PRIVATE_KEY:
from_secret: SERVER_PRIVATE_KEY from_secret: SERVER_PRIVATE_KEY
commands: commands:
- chmod +x ci/deploy.sh - chmod +x ci/deploy.sh
- ci/deploy.sh - ci/deploy.sh

@ -1,18 +0,0 @@
set -e
export OUTPUT=$1
export BASE=$2
rm -rf "$OUTPUT"/*
echo "VITE_API_ENDPOINT=$BASE/api" >> .env.PROD
echo "VITE_BASE=$BASE" >> .env.PROD
ci/build_react.msh
mkdir -p $OUTPUT/profiles/
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
cp -r vendor sql src public $OUTPUT

@ -1,17 +1,20 @@
#!/usr/bin/env moshell #!/usr/bin/env moshell
val base = std::env("BASE").unwrap() mkdir -p /outputs/public
val outputs = std::env("OUTPUT").unwrap()
mkdir -p $outputs/public apt update && apt install jq -y
val drone_branch = std::env("DRONE_BRANCH").unwrap()
val base = "/IQBall/$drone_branch/public"
npm run build -- --base=$base --mode PROD npm run build -- --base=$base --mode PROD
// Read generated mappings from build // Read generated mappings from build
val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json) val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json)
val mappings = $result.split('\n') val mappings = $result.split('\n')
echo '<?php const ASSETS = [' > views-mappings.php
echo '<?php\nconst ASSETS = [' > views-mappings.php
while $mappings.len() > 0 { while $mappings.len() > 0 {
val mapping = $mappings.pop().unwrap(); val mapping = $mappings.pop().unwrap();
@ -25,5 +28,5 @@ echo "];" >> views-mappings.php
chmod +r views-mappings.php chmod +r views-mappings.php
cp -r dist/* front/assets/ front/style/ public/* $outputs/public/ mv dist/* front/assets/ front/style/ public/* /outputs/public/
cp -r views-mappings.php $outputs/ mv views-mappings.php /outputs/

@ -5,7 +5,7 @@ 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

@ -0,0 +1,18 @@
{
"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"
}
}

@ -0,0 +1,9 @@
#!/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

@ -6,4 +6,4 @@ 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.BASE_URL.slice(0, import.meta.env.BASE_URL.length - 1) export const BASE = import.meta.env.VITE_BASE

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

Before

Width:  |  Height:  |  Size: 747 B

After

Width:  |  Height:  |  Size: 747 B

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 507 B

After

Width:  |  Height:  |  Size: 507 B

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 732 B

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 405 B

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

@ -1,16 +1,16 @@
import { CSSProperties, useRef, useState } from "react" import React, { CSSProperties, useRef, useState } from "react"
import "../style/title_input.css" import "../style/title_input.css"
export interface TitleInputOptions { export interface TitleInputOptions {
style: CSSProperties style: CSSProperties
default_value: string default_value: string
onValidated: (a: string) => void on_validated: (a: string) => void
} }
export default function TitleInput({ export default function TitleInput({
style, style,
default_value, default_value,
onValidated, on_validated,
}: TitleInputOptions) { }: TitleInputOptions) {
const [value, setValue] = useState(default_value) const [value, setValue] = useState(default_value)
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
@ -23,7 +23,7 @@ export default function TitleInput({
type="text" type="text"
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onBlur={() => onValidated(value)} onBlur={(_) => on_validated(value)}
onKeyUp={(event) => { onKeyUp={(event) => {
if (event.key == "Enter") ref.current?.blur() if (event.key == "Enter") ref.current?.blur()
}} }}

@ -44,16 +44,18 @@ export default function ArrowAction({
) )
} }
export function ScreenHead({ color }: { color: string }) { export function ScreenHead() {
return ( return (
<div style={{ backgroundColor: color, height: "5px", width: "25px" }} /> <div
style={{ backgroundColor: "black", height: "5px", width: "25px" }}
/>
) )
} }
export function MoveToHead({ color }: { color: string }) { export function MoveToHead() {
return ( return (
<svg viewBox={"0 0 50 50"} width={20} height={20}> <svg viewBox={"0 0 50 50"} width={20} height={20}>
<polygon points={"50 0, 0 0, 25 40"} fill={color} /> <polygon points={"50 0, 0 0, 25 40"} fill="#000" />
</svg> </svg>
) )
} }

@ -1,19 +1,15 @@
import { BallPiece } from "../editor/BallPiece" import { BallPiece } from "../editor/BallPiece"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { useRef } from "react" import { useRef } from "react"
import { NULL_POS } from "../../geo/Pos"
export interface BallActionProps { export interface BallActionProps {
onDrop: (el: DOMRect) => void onDrop: (el: HTMLElement) => void
} }
export default function BallAction({ onDrop }: BallActionProps) { export default function BallAction({ onDrop }: BallActionProps) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
return ( return (
<Draggable <Draggable onStop={() => onDrop(ref.current!)} nodeRef={ref}>
nodeRef={ref}
onStop={() => onDrop(ref.current!.getBoundingClientRect())}
position={NULL_POS}>
<div ref={ref}> <div ref={ref}>
<BallPiece /> <BallPiece />
</div> </div>

@ -1,6 +1,5 @@
import { import {
CSSProperties, CSSProperties,
MouseEvent as ReactMouseEvent,
ReactElement, ReactElement,
RefObject, RefObject,
useCallback, useCallback,
@ -8,29 +7,29 @@ import {
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
MouseEvent as ReactMouseEvent,
} from "react" } from "react"
import { import {
add, add,
angle, angle,
distance,
middle, middle,
distance,
middlePos, middlePos,
minus, minus,
mul, mul,
norm,
NULL_POS,
Pos, Pos,
posWithinBase, posWithinBase,
ratioWithinBase, ratioWithinBase,
relativeTo, relativeTo,
} from "../../geo/Pos" norm,
} from "./Pos"
import "../../style/bendable_arrows.css" import "../../style/bendable_arrows.css"
import Draggable from "react-draggable" import Draggable from "react-draggable"
export interface BendableArrowProps { export interface BendableArrowProps {
area: RefObject<HTMLElement> area: RefObject<HTMLElement>
startPos: Pos | string startPos: Pos
segments: Segment[] segments: Segment[]
onSegmentsChanges: (edges: Segment[]) => void onSegmentsChanges: (edges: Segment[]) => void
forceStraight: boolean forceStraight: boolean
@ -47,18 +46,16 @@ export interface BendableArrowProps {
export interface ArrowStyle { export interface ArrowStyle {
width?: number width?: number
dashArray?: string dashArray?: string
color: string
head?: () => ReactElement head?: () => ReactElement
tail?: () => ReactElement tail?: () => ReactElement
} }
const ArrowStyleDefaults: ArrowStyle = { const ArrowStyleDefaults: ArrowStyle = {
width: 3, width: 3,
color: "black",
} }
export interface Segment { export interface Segment {
next: Pos | string next: Pos
controlPoint?: Pos controlPoint?: Pos
} }
@ -137,7 +134,7 @@ export default function BendableArrow({
} }
}) })
}, },
[startPos], [segments, startPos],
) )
// Cache the segments so that when the user is changing the segments (it moves an ArrowPoint), // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint),
@ -150,7 +147,7 @@ export default function BendableArrow({
// If the (original) segments changes, overwrite the current ones. // If the (original) segments changes, overwrite the current ones.
useLayoutEffect(() => { useLayoutEffect(() => {
setInternalSegments(computeInternalSegments(segments)) setInternalSegments(computeInternalSegments(segments))
}, [computeInternalSegments, segments]) }, [startPos, segments, computeInternalSegments])
const [isSelected, setIsSelected] = useState(false) const [isSelected, setIsSelected] = useState(false)
@ -165,8 +162,8 @@ export default function BendableArrow({
return segments.flatMap(({ next, controlPoint }, i) => { return segments.flatMap(({ next, controlPoint }, i) => {
const prev = i == 0 ? startPos : segments[i - 1].next const prev = i == 0 ? startPos : segments[i - 1].next
const prevRelative = getPosWithinBase(prev, parentBase) const prevRelative = posWithinBase(prev, parentBase)
const nextRelative = getPosWithinBase(next, parentBase) const nextRelative = posWithinBase(next, parentBase)
const cpPos = const cpPos =
controlPoint || controlPoint ||
@ -207,7 +204,7 @@ export default function BendableArrow({
<ArrowPoint <ArrowPoint
key={i + "-2"} key={i + "-2"}
className={"arrow-point-next"} className={"arrow-point-next"}
posRatio={getRatioWithinBase(next, parentBase)} posRatio={next}
parentBase={parentBase} parentBase={parentBase}
onPosValidated={(next) => { onPosValidated={(next) => {
const currentSegment = segments[i] const currentSegment = segments[i]
@ -255,19 +252,19 @@ export default function BendableArrow({
const lastSegment = internalSegments[internalSegments.length - 1] const lastSegment = internalSegments[internalSegments.length - 1]
const startRelative = getPosWithinBase(startPos, parentBase) const startRelative = posWithinBase(startPos, parentBase)
const endRelative = getPosWithinBase(lastSegment.end, parentBase) const endRelative = posWithinBase(lastSegment.end, parentBase)
const startNext = const startNext =
segment.controlPoint && !forceStraight segment.controlPoint && !forceStraight
? posWithinBase(segment.controlPoint, parentBase) ? posWithinBase(segment.controlPoint, parentBase)
: getPosWithinBase(segment.end, parentBase) : posWithinBase(segment.end, parentBase)
const endPrevious = forceStraight const endPrevious = forceStraight
? startRelative ? startRelative
: lastSegment.controlPoint : lastSegment.controlPoint
? posWithinBase(lastSegment.controlPoint, parentBase) ? posWithinBase(lastSegment.controlPoint, parentBase)
: getPosWithinBase(lastSegment.start, parentBase) : posWithinBase(lastSegment.start, parentBase)
const tailPos = constraintInCircle( const tailPos = constraintInCircle(
startRelative, startRelative,
@ -312,15 +309,15 @@ export default function BendableArrow({
}, },
] ]
: internalSegments : internalSegments
).map(({ start, controlPoint, end }) => { ).map(({ start, controlPoint, end }, idx) => {
const svgPosRelativeToBase = { x: left, y: top } const svgPosRelativeToBase = { x: left, y: top }
const nextRelative = relativeTo( const nextRelative = relativeTo(
getPosWithinBase(end, parentBase), posWithinBase(end, parentBase),
svgPosRelativeToBase, svgPosRelativeToBase,
) )
const startRelative = relativeTo( const startRelative = relativeTo(
getPosWithinBase(start, parentBase), posWithinBase(start, parentBase),
svgPosRelativeToBase, svgPosRelativeToBase,
) )
const controlPointRelative = const controlPointRelative =
@ -358,14 +355,14 @@ export default function BendableArrow({
? add(start, previousSegmentCpAndCurrentPosVector) ? add(start, previousSegmentCpAndCurrentPosVector)
: cp : cp
if (forceStraight) {
return `L${end.x} ${end.y}`
}
if (wavy) { if (wavy) {
return wavyBezier(start, smoothCp, cp, end, 10, 10) return wavyBezier(start, smoothCp, cp, end, 10, 10)
} }
if (forceStraight) {
return `L${end.x} ${end.y}`
}
return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}` return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}`
}) })
.join(" ") .join(" ")
@ -374,34 +371,17 @@ export default function BendableArrow({
pathRef.current!.setAttribute("d", d) pathRef.current!.setAttribute("d", d)
Object.assign(svgRef.current!.style, svgStyle) Object.assign(svgRef.current!.style, svgStyle)
}, [ }, [
area,
internalSegments,
startPos, startPos,
internalSegments,
forceStraight, forceStraight,
startRadius, startRadius,
endRadius, endRadius,
wavy, style,
]) ])
// Will update the arrow when the props change // Will update the arrow when the props change
useEffect(update, [update]) useEffect(update, [update])
useEffect(() => {
const observer = new MutationObserver(update)
const config = { attributes: true }
if (typeof startPos == "string") {
observer.observe(document.getElementById(startPos)!, config)
}
for (const segment of segments) {
if (typeof segment.next == "string") {
observer.observe(document.getElementById(segment.next)!, config)
}
}
return () => observer.disconnect()
}, [startPos, segments, update])
// Adds a selection handler // Adds a selection handler
// Also force an update when the window is resized // Also force an update when the window is resized
useEffect(() => { useEffect(() => {
@ -438,16 +418,10 @@ export default function BendableArrow({
for (let i = 0; i < segments.length; i++) { for (let i = 0; i < segments.length; i++) {
const segment = segments[i] const segment = segments[i]
const beforeSegment = i != 0 ? segments[i - 1] : undefined const beforeSegment = i != 0 ? segments[i - 1] : undefined
const beforeSegmentPos = getRatioWithinBase( const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos
i > 1 ? segments[i - 2].next : startPos,
parentBase,
)
const currentPos = getRatioWithinBase( const currentPos = beforeSegment ? beforeSegment.next : startPos
beforeSegment ? beforeSegment.next : startPos, const nextPos = segment.next
parentBase,
)
const nextPos = getRatioWithinBase(segment.next, parentBase)
const segmentCp = segment.controlPoint const segmentCp = segment.controlPoint
? segment.controlPoint ? segment.controlPoint
: middle(currentPos, nextPos) : middle(currentPos, nextPos)
@ -519,7 +493,7 @@ export default function BendableArrow({
<path <path
className="arrow-path" className="arrow-path"
ref={pathRef} ref={pathRef}
stroke={style?.color ?? ArrowStyleDefaults.color} stroke={"#000"}
strokeWidth={styleWidth} strokeWidth={styleWidth}
strokeDasharray={ strokeDasharray={
style?.dashArray ?? ArrowStyleDefaults.dashArray style?.dashArray ?? ArrowStyleDefaults.dashArray
@ -555,24 +529,6 @@ export default function BendableArrow({
) )
} }
function getPosWithinBase(target: Pos | string, area: DOMRect): Pos {
if (typeof target != "string") {
return posWithinBase(target, area)
}
const targetPos = document.getElementById(target)?.getBoundingClientRect()
return targetPos ? relativeTo(middlePos(targetPos), area) : NULL_POS
}
function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos {
if (typeof target != "string") {
return target
}
const targetPos = document.getElementById(target)?.getBoundingClientRect()
return targetPos ? ratioWithinBase(middlePos(targetPos), area) : NULL_POS
}
interface ControlPointProps { interface ControlPointProps {
className: string className: string
posRatio: Pos posRatio: Pos
@ -590,9 +546,9 @@ enum PointSegmentSearchResult {
} }
interface FullSegment { interface FullSegment {
start: Pos | string start: Pos
controlPoint: Pos | null controlPoint: Pos | null
end: Pos | string end: Pos
} }
/** /**

@ -28,14 +28,6 @@ export function surrounds(pos: Pos, width: number, height: number): Box {
} }
} }
export function overlaps(a: Box, b: Box): boolean {
if (a.x + a.width < b.x || b.x + b.width < a.x) {
return false
}
return !(a.y + a.height < b.y || b.y + b.height < a.y)
}
export function contains(box: Box, pos: Pos): boolean { export function contains(box: Box, pos: Pos): boolean {
return ( return (
pos.x >= box.x && pos.x >= box.x &&

@ -1,8 +1,7 @@
import "../../style/ball.css" import "../../style/ball.css"
import BallSvg from "../../assets/icon/ball.svg?react" import BallSvg from "../../assets/icon/ball.svg?react"
import { BALL_ID } from "../../model/tactic/CourtObjects"
export function BallPiece() { export function BallPiece() {
return <BallSvg id={BALL_ID} className={"ball"} /> return <BallSvg className={"ball"} />
} }

@ -0,0 +1,272 @@
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,16 +1,15 @@
import { useRef } from "react" import React, { useRef } from "react"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece" import { BallPiece } from "./BallPiece"
import { NULL_POS } from "../../geo/Pos" import { Ball } from "../../model/tactic/Ball"
import { Ball } from "../../model/tactic/CourtObjects"
export interface CourtBallProps { export interface CourtBallProps {
onPosValidated: (rect: DOMRect) => void onMoved: (rect: DOMRect) => void
onRemove: () => void onRemove: () => void
ball: Ball ball: Ball
} }
export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
const x = ball.rightRatio const x = ball.rightRatio
@ -18,10 +17,7 @@ export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
return ( return (
<Draggable <Draggable
onStop={() => onStop={() => onMoved(pieceRef.current!.getBoundingClientRect())}
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
position={NULL_POS}
nodeRef={pieceRef}> nodeRef={pieceRef}>
<div <div
className={"ball-div"} className={"ball-div"}

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

@ -1,6 +1,6 @@
import { Pos } from "../../geo/Pos" import { Pos } from "../../components/arrows/Pos"
import { Segment } from "../../components/arrows/BendableArrow" import { Segment } from "../../components/arrows/BendableArrow"
import { ComponentId } from "./Tactic" import { PlayerId } from "./Player"
export enum ActionKind { export enum ActionKind {
SCREEN = "SCREEN", SCREEN = "SCREEN",
@ -12,10 +12,8 @@ export enum ActionKind {
export type Action = { type: ActionKind } & MovementAction export type Action = { type: ActionKind } & MovementAction
export interface MovementAction { export interface MovementAction {
target: ComponentId | Pos fromPlayerId: PlayerId
toPlayerId?: PlayerId
moveFrom: Pos
segments: Segment[] segments: Segment[]
} }
export function moves(kind: ActionKind): boolean {
return kind != ActionKind.SHOOT
}

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

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

@ -0,0 +1,15 @@
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[]
}

@ -5,7 +5,6 @@
.arrow-action-icon { .arrow-action-icon {
user-select: none; user-select: none;
-moz-user-select: none; -moz-user-select: none;
-webkit-user-drag: none;
max-width: 17px; max-width: 17px;
max-height: 17px; max-height: 17px;
} }

@ -23,7 +23,6 @@
} }
#topbar-div { #topbar-div {
width: 100%;
display: flex; display: flex;
background-color: var(--main-color); background-color: var(--main-color);
margin-bottom: 3px; margin-bottom: 3px;

@ -1,4 +1,4 @@
@import url(../theme/default.css); @import url(../theme/dark.css);
@import url(personnal_space.css); @import url(personnal_space.css);
@import url(side_menu.css); @import url(side_menu.css);
@import url(../component/header.css); @import url(../component/header.css);
@ -21,7 +21,7 @@
.data { .data {
border: 1.5px solid var(--main-contrast-color); border: 1.5px solid var(--main-contrast-color);
background-color: var(--home-main-color); background-color: var(--main-color);
border-radius: 0.75cap; border-radius: 0.75cap;
color: var(--main-contrast-color); color: var(--main-contrast-color);
} }

@ -1,7 +1,7 @@
@import url(../theme/default.css); @import url(../theme/dark.css);
#side-menu { #side-menu {
background-color: var(--home-third-color); background-color: var(--third-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -17,7 +17,7 @@
width: 90%; width: 90%;
} }
.titre-side-menu { .titre-side-menu {
border-bottom: var(--home-main-color) solid 3px; border-bottom: var(--main-color) solid 3px;
width: 100%; width: 100%;
margin-bottom: 3%; margin-bottom: 3%;
} }
@ -28,7 +28,7 @@
color: var(--main-contrast-color); color: var(--main-contrast-color);
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
background-color: var(--home-main-color); background-color: var(--main-color);
padding: 3%; padding: 3%;
margin-bottom: 0px; margin-bottom: 0px;
margin-right: 3%; margin-right: 3%;

@ -2,10 +2,6 @@
pointer-events: none; pointer-events: none;
} }
.phantom {
opacity: 50%;
}
.player-content { .player-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

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

@ -21,12 +21,4 @@
--player-piece-ball-border-color: #000000; --player-piece-ball-border-color: #000000;
--text-main-font: "Roboto", sans-serif; --text-main-font: "Roboto", sans-serif;
--home-main-color: #191a21;
--home-second-color: #282a36;
--home-third-color: #303341;
--accent-color: #ffa238;
--main-contrast-color: #e6edf3;
--font-title: Helvetica;
--font-content: Helvetica;
} }

@ -1,75 +1,26 @@
import "../style/home/home.css" import "../style/home/home.css"
// import AccountSvg from "../assets/account.svg?react"
import { Header } from "./template/Header"
import { BASE } from "../Constants" import { BASE } from "../Constants"
import { MainTitle } from "./component/Title" import { MainTitle } from "./component/Title"
import { Tactic } from "./model/Tactic" import { Tactic } from "./model/Tactic"
import { Team } from "./model/Team" import { Team } from "./model/Team"
import { getSession } from "../api/session.ts"
import { useNavigate } from "react-router-dom"
import { startTransition, useLayoutEffect, useState } from "react"
import { User } from "../model/User.ts"
import { fetchAPIGet } from "../Fetcher.ts"
interface Tactic { export default function Home({
id: number
name: string
creationDate: number
}
interface Team {
id: number
name: string
picture: string
main_color: string
second_color: string
}
export default function HomePage() {
type UserDataResponse = { user?: User; tactics: Tactic[]; teams: Team[] }
const [{ tactics, teams }, setInfo] = useState<UserDataResponse>({
tactics: [],
teams: [],
})
const navigate = useNavigate()
useLayoutEffect(() => {
const session = getSession()
if (!session.auth) {
startTransition(() => {
navigate("/login")
})
return
}
async function getUser() {
const response = await fetchAPIGet("user-data")
setInfo(await response.json())
}
getUser()
}, [navigate])
tactics!.sort((a, b) => b.creationDate - a.creationDate)
const lastTactics = tactics.slice(0, 5)
return (
<Home teams={teams!} allTactics={tactics!} lastTactics={lastTactics} />
)
}
function Home({
lastTactics, lastTactics,
allTactics, allTactics,
teams, teams,
username,
}: { }: {
lastTactics: Tactic[] lastTactics: Tactic[]
allTactics: Tactic[] allTactics: Tactic[]
teams: Team[] teams: Team[]
username: string
}) { }) {
return ( return (
<div id="main"> <div id="main">
<Header username={username} />
<Body <Body
lastTactics={lastTactics} lastTactics={lastTactics}
allTactics={allTactics} allTactics={allTactics}

@ -3,15 +3,9 @@ import "../style/new_tactic_panel.css"
import plainCourt from "../assets/court/full_court.svg" import plainCourt from "../assets/court/full_court.svg"
import halfCourt from "../assets/court/half_court.svg" import halfCourt from "../assets/court/half_court.svg"
import { CourtType } from "../model/tactic/Tactic.ts" import { BASE } from "../Constants"
import { startTransition, useCallback } from "react"
import { fetchAPI } from "../Fetcher.ts"
import { getSession } from "../api/session.ts"
import { useNavigate } from "react-router-dom"
export const DEFAULT_TACTIC_NAME = "Nouvelle tactique" export default function NewTacticPanel() {
export default function NewTacticPage() {
return ( return (
<div id={"panel-root"}> <div id={"panel-root"}>
<div id={"panel-top"}> <div id={"panel-top"}>
@ -22,12 +16,12 @@ export default function NewTacticPage() {
<CourtKindButton <CourtKindButton
name="Terrain complet" name="Terrain complet"
image={plainCourt} image={plainCourt}
courtType={"PLAIN"} redirect="/tactic/new/plain"
/> />
<CourtKindButton <CourtKindButton
name="Demi-terrain" name="Demi-terrain"
image={halfCourt} image={halfCourt}
courtType={"HALF"} redirect="/tactic/new/half"
/> />
</div> </div>
</div> </div>
@ -38,40 +32,16 @@ export default function NewTacticPage() {
function CourtKindButton({ function CourtKindButton({
name, name,
image, image,
courtType, redirect,
}: { }: {
name: string name: string
image: string image: string
courtType: CourtType redirect: string
}) { }) {
const navigate = useNavigate()
return ( return (
<div <div
className="court-kind-button" className="court-kind-button"
onClick={useCallback(async () => { onClick={() => (location.href = BASE + redirect)}>
// if user is not authenticated
if (!getSession().auth) {
startTransition(() => {
navigate(`/tactic/edit-guest`)
})
}
const response = await fetchAPI(
"tactics",
{
name: DEFAULT_TACTIC_NAME,
courtType,
},
"POST",
)
const { id } = await response.json()
startTransition(() => {
navigate(`/tactic/${id}/edit`)
})
}, [courtType, navigate])}>
<div className="court-kind-button-top"> <div className="court-kind-button-top">
<div className="court-kind-button-image-div"> <div className="court-kind-button-image-div">
<img <img

@ -1,28 +1,9 @@
import "../style/team_panel.css" import "../style/team_panel.css"
import { BASE } from "../Constants" import { BASE } from "../Constants"
import { Member, Team, TeamInfo } from "../model/Team" import { Team, TeamInfo, Member } from "../model/Team"
import { useParams } from "react-router-dom" import { User } from "../model/User"
export default function TeamPanelPage() { export default function TeamPanel({
const { teamId } = useParams()
const teamInfo = {
id: parseInt(teamId!),
name: teamId!,
mainColor: "#FFFFFF",
secondColor: "#000000",
picture:
"https://a.espncdn.com/combiner/i?img=/i/teamlogos/nba/500/lal.png",
}
return (
<TeamPanel
team={{ info: teamInfo, members: [] }}
currentUserId={0}
isCoach={false}
/>
)
}
function TeamPanel({
isCoach, isCoach,
team, team,
currentUserId, currentUserId,

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

@ -2,36 +2,29 @@ import { Action, ActionKind } from "../../model/tactic/Action"
import BendableArrow from "../../components/arrows/BendableArrow" import BendableArrow from "../../components/arrows/BendableArrow"
import { RefObject } from "react" import { RefObject } from "react"
import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction"
import { ComponentId } from "../../model/tactic/Tactic"
export interface CourtActionProps { export interface CourtActionProps {
origin: ComponentId
action: Action action: Action
onActionChanges: (a: Action) => void onActionChanges: (a: Action) => void
onActionDeleted: () => void onActionDeleted: () => void
courtRef: RefObject<HTMLElement> courtRef: RefObject<HTMLElement>
isInvalid: boolean
} }
export function CourtAction({ export function CourtAction({
origin,
action, action,
onActionChanges, onActionChanges,
onActionDeleted, onActionDeleted,
courtRef, courtRef,
isInvalid,
}: CourtActionProps) { }: CourtActionProps) {
const color = isInvalid ? "red" : "black"
let head let head
switch (action.type) { switch (action.type) {
case ActionKind.DRIBBLE: case ActionKind.DRIBBLE:
case ActionKind.MOVE: case ActionKind.MOVE:
case ActionKind.SHOOT: case ActionKind.SHOOT:
head = () => <MoveToHead color={color} /> head = () => <MoveToHead />
break break
case ActionKind.SCREEN: case ActionKind.SCREEN:
head = () => <ScreenHead color={color} /> head = () => <ScreenHead />
break break
} }
@ -46,20 +39,19 @@ export function CourtAction({
<BendableArrow <BendableArrow
forceStraight={action.type == ActionKind.SHOOT} forceStraight={action.type == ActionKind.SHOOT}
area={courtRef} area={courtRef}
startPos={origin} startPos={action.moveFrom}
segments={action.segments} segments={action.segments}
onSegmentsChanges={(edges) => { onSegmentsChanges={(edges) => {
onActionChanges({ ...action, segments: edges }) onActionChanges({ ...action, segments: edges })
}} }}
wavy={action.type == ActionKind.DRIBBLE} wavy={action.type == ActionKind.DRIBBLE}
//TODO place those magic values in constants //TODO place those magic values in constants
endRadius={action.target ? 26 : 17} endRadius={action.toPlayerId ? 26 : 17}
startRadius={10} startRadius={0}
onDeleteRequested={onActionDeleted} onDeleteRequested={onActionDeleted}
style={{ style={{
head, head,
dashArray, dashArray,
color,
}} }}
/> />
) )

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

@ -20,9 +20,7 @@
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0", "vite-plugin-css-injected-by-js": "^3.3.0"
"eslint-plugin-react-refresh": "^0.4.5",
"react-router-dom": "^6.22.0"
}, },
"scripts": { "scripts": {
"start": "vite --host", "start": "vite --host",
@ -43,11 +41,6 @@
"@vitejs/plugin-react": "^4.1.0", "@vitejs/plugin-react": "^4.1.0",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0", "vite-plugin-svgr": "^4.1.0"
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0"
} }
} }

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

@ -0,0 +1,16 @@
<?php
$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;
}

@ -0,0 +1,22 @@
<?php
// This file only exists on production servers, and defines the available assets mappings
// in an `ASSETS` array constant.
require __DIR__ . "/../views-mappings.php";
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);
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M234-276q51-39 114-61.5T480-360q69 0 132 22.5T726-276q35-41 54.5-93T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 59 19.5 111t54.5 93Zm246-164q-59 0-99.5-40.5T340-580q0-59 40.5-99.5T480-720q59 0 99.5 40.5T620-580q0 59-40.5 99.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q53 0 100-15.5t86-44.5q-39-29-86-44.5T480-280q-53 0-100 15.5T294-220q39 29 86 44.5T480-160Zm0-360q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm0-60Zm0 360Z" fill="#e6edf3"/></svg>

After

Width:  |  Height:  |  Size: 747 B

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

Loading…
Cancel
Save