diff --git a/.env b/.env index 98ae12d..5fc3483 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -VITE_API_ENDPOINT=/api -VITE_BASE= \ No newline at end of file +VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master +#VITE_API_ENDPOINT=http://localhost:5254 diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..da35694 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,20 @@ +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 }, + ], + }, +} diff --git a/.gitignore b/.gitignore index 3934c5c..780376a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,25 @@ -.vs -.vscode -.idea -.code -.vite - -vendor -.nfs* -composer.lock -*.phar -/dist -.guard - -# sqlite database files -*.sqlite - -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 -.env.local -.env.development.local -.env.test.local -.env.production.local - +# Logs +logs +*.log npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? -.php-cs-fixer.cache \ No newline at end of file +package-lock.json \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php deleted file mode 100644 index 77ef0e7..0000000 --- a/.php-cs-fixer.php +++ /dev/null @@ -1,16 +0,0 @@ -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); diff --git a/.prettierrc b/.prettierrc index 7db0434..2b63492 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { - "bracketSameLine": true, - "trailingComma": "all", - "printWidth": 80, - "tabWidth": 4, - "semi": false -} \ No newline at end of file + "bracketSameLine": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 4, + "semi": false +} diff --git a/.stage-backend-branch b/.stage-backend-branch new file mode 100644 index 0000000..8b25206 --- /dev/null +++ b/.stage-backend-branch @@ -0,0 +1 @@ +master \ No newline at end of file diff --git a/Documentation/Conception.md b/Documentation/Conception.md index 68b4cd9..31249e0 100644 --- a/Documentation/Conception.md +++ b/Documentation/Conception.md @@ -4,25 +4,27 @@ Notre projet est divisé en plusieurs parties: -- `src/API`, qui définit les classes qui implémentent les actions de l’api -- `src/App`, qui définit les contrôleurs et les vues de l’application 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 l’application et les actions que l’ont 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 n’est pas initialisée. -- `profiles`, définit les profiles d’execution, voir `Documentation/how-to-dev.md` pour plus d’info -- `front` contient le code front-end react/typescript -- `ci` contient les scripts de déploiement et la définition du workflow d’intégration continue et de déploiement constant vers notre staging server ([maxou.dev//public/](https://maxou.dev/IQBall/master/public)). -- `public` point d’entrée, avec : - - `public/index.php` point d’entrée pour la webapp - - `public/api/index.php` point d’entrée pour l’api. +- `src/API`, qui définit les classes qui implémentent les actions de l’api +- `src/App`, qui définit les contrôleurs et les vues de l’application 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 l’application et les actions que l’ont 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 n’est pas initialisée. +- `profiles`, définit les profiles d’execution, voir `Documentation/how-to-dev.md` pour plus d’info +- `front` contient le code front-end react/typescript +- `ci` contient les scripts de déploiement et la définition du workflow d’intégration continue et de déploiement constant vers notre staging server ([maxou.dev//public/](https://maxou.dev/IQBall/master/public)). +- `public` point d’entrée, avec : + - `public/index.php` point d’entrée pour la webapp + - `public/api/index.php` point d’entrée pour l’api. ## Backend ### Validation et résilience des erreurs + #### 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 -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 public function doPostAction(array $form) { @@ -39,11 +41,11 @@ public function doPostAction(array $form) { if (Validation::isLenBetween($email, 6, 64))) { $failures[] = "L'adresse email doit être d'une longueur comprise entre 6 et 64 charactères."; } - + if (!empty($failures)) { return ViewHttpResponse::twig('error.html.twig', ['failures' => $failures]); } - + // traitement ... } ``` @@ -56,27 +58,29 @@ 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. #### 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. 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 public function doPostAction(array $form): HttpResponse { $failures = []; $req = HttpRequest::from($form, $failures, [ - 'email' => [Validators::email(), Validators::isLenBetween(6, 64)] + 'email' => [DefaultValidators::email(), DefaultValidators::isLenBetween(6, 64)] ]); - + if (!empty($failures)) { //ou $req == null return ViewHttpResponse::twig('error.html.twig', ['failures' => $failures]) } - + // 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, 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. Nous pouvons ensuite emballer les erreurs de validation dans des `ValidationFail` et `FieldValidationFail`, ce qui permet ensuite d'obtenir @@ -84,40 +88,41 @@ 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. ### HttpRequest, HttpResponse + 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, -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. C'est ensuite à la classe `src/App/App` d'afficher la réponse. ### 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, -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'affichage dans une classe (`IQBall\App\App` et `IQBall\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. 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 aurait eu pour conséquences de recharger la page - ## Frontend ### Utilisation de React 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. -Une stratégie est un arbre composé de plusieurs étapes, une étape étant constituée d’un ensemble de joueurs et d’adversaires sur le terrain, +Une stratégie est un arbre composé de plusieurs étapes, une étape étant constituée d’un ensemble de joueurs et d’adversaires 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é. les enfants d’une étape, sont d’autres é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 l’interface soit réactive : si l’on bouge un joueur, +Pour rendre le tout agréable à utiliser, il faut que l’interface soit réactive : si l’on 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… 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 l’effectuer en utilisant le framework React qui rend simple le développement d’interfaces dynamiques, et d’utiliser typescript parce qu’ici on code bien et qu’on impose une type safety a notre code. - diff --git a/Documentation/Description.md b/Documentation/Description.md new file mode 100644 index 0000000..fecd0e0 --- /dev/null +++ b/Documentation/Description.md @@ -0,0 +1,90 @@ +# 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) diff --git a/Documentation/README.md b/Documentation/README.md index dfc91c7..6cc1eb2 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -1,3 +1,3 @@ -# The wiki also exists - -Some of our explanation are contained in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki) \ No newline at end of file +- [Description.md](Description.md) +- [Conception.md](Conception.md) +- [how-to-dev.md](how-to-dev.md) diff --git a/Documentation/architecture.puml b/Documentation/architecture.puml new file mode 100644 index 0000000..3e8f98f --- /dev/null +++ b/Documentation/architecture.puml @@ -0,0 +1,60 @@ +@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 \ No newline at end of file diff --git a/Documentation/assets/architecture.svg b/Documentation/assets/architecture.svg new file mode 100644 index 0000000..a93591b --- /dev/null +++ b/Documentation/assets/architecture.svg @@ -0,0 +1 @@ +frontsrcAppCoreassetscomponentsmodelstyleviewssqlApiControllerSessionViewsDataGatewayHttpModelValidationConnection \ No newline at end of file diff --git a/Documentation/assets/auth.svg b/Documentation/assets/auth.svg new file mode 100644 index 0000000..b25a316 --- /dev/null +++ b/Documentation/assets/auth.svg @@ -0,0 +1 @@ +AuthController__construct (model : AuthModel)displayRegister() : HttpResponseregister(request : array,session : MutableSessionHandle) : HttpResponsedisplayLogin() : HttpResponselogin(request : array , session : MutableSessionHandle) : HttpResponseAuthModel__construct(gateway : AccountGateway)register(username:string, password:string, confirmPassword:string, email:string, &failures:array): ?Account + generateToken() : stringgenerateToken(): stringlogin(email:string, password:string, &failures:array): ?AccountAccountGateway__construct(con : Connexion)insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): intgetRowsFromMail(email:string): ?arraygetHash(email:string): ?stringexists(email:string): boolgetAccountFromMail(email:string): ?AccountgetAccountFromToken(token:string): ?AccountConnexion- model- gateway- con \ No newline at end of file diff --git a/Documentation/assets/editor.svg b/Documentation/assets/editor.svg new file mode 100644 index 0000000..ddc8750 --- /dev/null +++ b/Documentation/assets/editor.svg @@ -0,0 +1 @@ +EditorController__construct (model : TacticModel)openEditorFor(tactic:TacticInfo): ViewHttpResponsecreateNew(): ViewHttpResponseopenTestEditor(courtType:CourtType): ViewHttpResponsecreateNewOfKind(type:CourtType, session:SessionHandle): ViewHttpResponseopenEditor(id:int, session:SessionHandle): ViewHttpResponseTacticModelTACTIC_DEFAULT_NAME:int {frozen}__construct(tactics : TacticInfoGateway)makeNew(name:string, ownerId:int, type:CourtType): TacticInfomakeNewDefault(ownerId:int, type:CourtType): ?TacticInfoget(id:int): ?TacticInfogetLast(nb:int, ownerId:int): arraygetAll(ownerId:int): ?arrayupdateName(id:int, name:string, authId:int): arrayupdateContent(id:int, json:string): ?ValidationFailTacticInfoGateway__construct(con : Connexion)get(id:int): ?TacticInfogetLast(nb:int, ownerId:int): ?arraygetAll(ownerId:int): ?arrayinsert(name:string, owner:int, type:CourtType): intupdateName(id:int, name:string): boolupdateContent(id:int, json:string): boolConnexionTacticValidatorvalidateAccess(tacticId:int, tactic:?TacticInfo, ownerId:int): ?ValidationFail- model- tactics- con \ No newline at end of file diff --git a/Documentation/assets/http.svg b/Documentation/assets/http.svg new file mode 100644 index 0000000..67ddc0a --- /dev/null +++ b/Documentation/assets/http.svg @@ -0,0 +1 @@ +HttpRequestdata: array__construct(data: array) offsetExists(offset: mixed): booloffsetGet(offset: mixed): mixedoffsetSet(offset: mixed, value: mixed)offsetUnset(offset: mixed) from(request: array, fails: &array, schema: array): HttpRequestfromPayload(fails: &array, schema: array): HttpRequestArrayAccessHttpResponsecode: intheaders : array__construct(code: int,headers:array)getCode(): intgetHeaders(): arrayredirect(url:string, code:int): HttpResponsefromCode(code: int): HttpResponseJsonHttpResponsepayload: mixed__construct(payload: mixed, code: int)getJson(): stringViewHttpResponseTWIG_VIEW: int {frozen}REACT_VIEW: int {frozen} file: stringarguments: arraykind: int__construct(kind: int, file: string, arguments: array, code: int)getViewKind(): intgetFile(): stringgetArguments(): array twig(file: string, arguments: array, code: int): ViewHttpResponsereact(file: string, arguments: array, code: int): ViewHttpResponseInto src/AppHttpCodesOK : int {frozen}FOUND : int {frozen}BAD_REQUEST : int {frozen}UNAUTHORIZED : int {frozen}FORBIDDEN : int {frozen}NOT_FOUND : int {frozen} \ No newline at end of file diff --git a/Documentation/assets/models.svg b/Documentation/assets/models.svg new file mode 100644 index 0000000..e691fa7 --- /dev/null +++ b/Documentation/assets/models.svg @@ -0,0 +1 @@ +TacticInfoid: intname: stringcreationDate: stringownerId: stringcontent: string__construct(id:int,name:string,creationDate:int,ownerId:int,courtType:CourtType,content:string)getId(): intgetOwnerId(): intgetCreationTimestamp(): intgetName(): stringgetContent(): stringgetCourtType() : CourtTypeCourtTypevalue : intCOURT_PLAIN : int {frozen}COURT_HALF : int {frozen}__construct(val:int)plain() : CourtTypehalf() : CourtTypename() : stringfromName(name:string) : CourtTypeisPlain() : boolisHalf() : boolBasically an evoluated enumAccounttoken: string__construct(token:string,user:User)getUser() : UsergetToken() : stringUserid : intname : stringemail : stringprofilePicture : string__construct(id : int,name : string,email: string,profilePicture:string)getId() : idgetName() : stringgetEmail() : stringgetProfilePicture() : stringMemberteamId: introle : string__construct(role : string)getUser(): UsergetTeamId(): intgetRole(): stringMember's role is either "Coach" or "Player"TeamInfoname: stringpicture: stringmainColor : stringsecondColor : string__construct(id:int,name:string,picture:string,mainColor:string,secondColor:string)getName(): stringgetPicture(): stringgetMainColor(): stringgetSecondColor(): stringBoth team's color are the hex code of the colorTeam__construct(info:TeamInfo,members: Member[])getInfo(): TeamInfolistMembers(): Member[]- courtType- user- user- info- members * \ No newline at end of file diff --git a/Documentation/assets/session.svg b/Documentation/assets/session.svg new file mode 100644 index 0000000..85ec404 --- /dev/null +++ b/Documentation/assets/session.svg @@ -0,0 +1 @@ +SessionHandlegetInitialTarget(): ?stringgetAccount(): ?AccountMutableSessionHandlesetInitialTarget(url:?string): voidsetAccount(account:Account): voiddestroy(): voidPhpSessionHandleinit(): selfgetAccount(): ?AccountgetInitialTarget(): ?stringsetAccount(account:Account): voidsetInitialTarget(url:?string): voiddestroy(): void \ No newline at end of file diff --git a/Documentation/assets/team.svg b/Documentation/assets/team.svg new file mode 100644 index 0000000..edf8a32 --- /dev/null +++ b/Documentation/assets/team.svg @@ -0,0 +1 @@ +TeamGateway__construct(con : Connexion)insert(name : string ,picture : string, mainColor : Color, secondColor : Color)listByName(name : string): arraygetTeamById(id:int): ?TeamInfogetTeamIdByName(name:string): ?intdeleteTeam(idTeam:int): voideditTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string)getAll(user:int): arrayConnexionMemberGateway__construct(con : Connexion)insert(idTeam:int, userId:int, role:string): voidgetMembersOfTeam(teamId:int): arrayremove(idTeam:int, idMember:int): voidisCoach(email:string, idTeam:int): boolisMemberOfTeam(idTeam:int, idCurrentUser:int): boolAccountGateway__construct(con : Connexion)insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): intgetRowsFromMail(email:string): ?arraygetHash(email:string): ?stringexists(email:string): boolgetAccountFromMail(email:string): ?AccountgetAccountFromToken(token:string): ?AccountTeamModel__construct(gateway : TeamGateway)createTeam(name : string,picture : string, mainColorValue : int, secondColorValue : int, errors : array)addMember(mail:string, teamId:int, role:string): intlistByName(name : string ,errors : array) : ?arraygetTeam(idTeam:int, idCurrentUser:int): ?TeamdeleteMember(idMember:int, teamId:int): intdeleteTeam(email:string, idTeam:int): intisCoach(idTeam:int, email:string): booleditTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string)getAll(user:int): arrayTeamController__construct( model : TeamModel)displayCreateTeam(session:SessionHandle): ViewHttpResponsedisplayDeleteMember(session:SessionHandle): ViewHttpResponsesubmitTeam(request:array, session:SessionHandle): HttpResponsedisplayListTeamByName(session:SessionHandle): ViewHttpResponselistTeamByName(request:array, session:SessionHandle): HttpResponsedeleteTeamById(id:int, session:SessionHandle): HttpResponsedisplayTeam(id:int, session:SessionHandle): ViewHttpResponsedisplayAddMember(idTeam:int, session:SessionHandle): ViewHttpResponseaddMember(idTeam:int, request:array, session:SessionHandle): HttpResponsedeleteMember(idTeam:int, idMember:int, session:SessionHandle): HttpResponsedisplayEditTeam(idTeam:int, session:SessionHandle): ViewHttpResponseeditTeam(idTeam:int, request:array, session:SessionHandle): HttpResponse- con- con- con- members- teams- teams- model \ No newline at end of file diff --git a/Documentation/assets/validation.svg b/Documentation/assets/validation.svg new file mode 100644 index 0000000..7f19c31 --- /dev/null +++ b/Documentation/assets/validation.svg @@ -0,0 +1 @@ +Validatorvalidate(name: string, val: mixed): arraythen(other: Validator): ValidatorComposedValidator__construct(first: Validator, then: Validator)validate(name: string, val: mixed): arraySimpleFunctionValidatorpredicate: callableerror_factory: callable__construct(predicate: callable, errorsFactory: callable)validate(name: string, val: mixed): arrayValidationFailkind: stringmessage: string__construct(kind: string, message: string)getMessage(): stringgetKind(): stringnotFound(message: string): ValidationFailunauthorized(message:string): ValidationFailerror(message:string): ValidationFailJsonSerializeFieldValidationFailfieldName: string__construct(fieldName: string, message: string)getFieldName(): stringjsonSerialize() invalidChars(fieldName: string): FieldValidationFailempty(fieldName: string): FieldValidationFailmissing(fieldName: string): FieldValidationFailValidation+ validate(val: mixed, valName: string, failures: &array, validators: Validator...): boolDefaultValidatorsnonEmpty(): ValidatorshorterThan(limit: int): ValidatoruserString(maxLen: int): Validatorregex(regex:string, msg:string): Validatorhex(msg:string): Validatorname(msg:string): ValidatornameWithSpaces(): ValidatorlenBetween(min:int, max:int): Validatoremail(msg:string): ValidatorisInteger(): ValidatorisIntInRange(min:int, max:int): ValidatorisURL(): ValidatorFunctionValidatorvalidate_fn: callable__construct(validate_fn:callable)validate(name:string, val:mixed): array- first- then \ No newline at end of file diff --git a/Documentation/database_mld.puml b/Documentation/database_mld.puml index 2c33ce2..8b4d32f 100644 --- a/Documentation/database_mld.puml +++ b/Documentation/database_mld.puml @@ -5,9 +5,9 @@ object Account { name age email - phoneNumber - passwordHash - profilePicture + phone_number + password_hash + profile_picture } object TacticFolder { diff --git a/Documentation/how-to-dev.md b/Documentation/how-to-dev.md index 39435e6..0834d22 100644 --- a/Documentation/how-to-dev.md +++ b/Documentation/how-to-dev.md @@ -1,19 +1,21 @@ +# THIS FILE IS DEPRECATED + +See [#107](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/pulls/107) for more details + +--- + This documentation file explains how to start a development server on your machine, and how it works under the hood. # How to run the project on my local computer +1. Use phpstorm to run a local php server: -1) Use phpstorm to run a local php server: -* Go to configuration > add new configuration -* Select "PHP Built-in Web Server", then enter options as follow: -![](assets/php-server-config.png) - - port 8080 - - name the configuration "RunServer" to be more explicit - - place the "Document Root" in `/public` - - host is localhost -* Click apply, OK -* Now run it. +- Go to configuration > add new configuration +- Select "PHP Built-in Web Server", then enter options as follow: + ![](assets/php-server-config.png) - port 8080 - name the configuration "RunServer" to be more explicit - place the "Document Root" in `/public` - host is localhost +- Click apply, OK +- Now run it. 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. @@ -39,12 +41,14 @@ Now refresh your page, you should now see all request being fulfilled and a form Caution: **NEVER** directly connect on the `localhost:5173` node development server, always pass through the php (`localhost:8080`) server. # 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. 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` + ```php require_once "react-display.php"; class SampleFormController { @@ -63,7 +67,7 @@ As our views are now done using react (and defined under the `front/views` folde If you look at the `send_react_front($viewURI, $viewArguments)` function, you'll see that is simply loads the file `src/react-display-file.php` with given arguments. The file is a simple html5 template with a ` ``` -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) 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). -The view file **must export by default its react function component**. +The view file **must export by default its react function component**. ## 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) (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. 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, -you'll see that it defines the `asset(string $uri)` function that is used by the `src/react-display-file.php`, -in the ` + + diff --git a/package.json b/package.json index f6ae21e..7f7b1cc 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "react-draggable": "^4.4.6", "typescript": "^5.2.2", "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": { "start": "vite --host", @@ -41,6 +43,11 @@ "@vitejs/plugin-react": "^4.1.0", "prettier": "^3.1.0", "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" } -} +} \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index 346baaa..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,12 +0,0 @@ -parameters: - phpVersion: 70400 - level: 6 - paths: - - src - scanFiles: - - config.php - - sql/database.php - - profiles/dev-config-profile.php - - profiles/prod-config-profile.php - excludePaths: - - src/App/react-display-file.php diff --git a/profiles/dev-config-profile.php b/profiles/dev-config-profile.php deleted file mode 100644 index bd87f1d..0000000 --- a/profiles/dev-config-profile.php +++ /dev/null @@ -1,16 +0,0 @@ - \ No newline at end of file diff --git a/public/assets b/public/assets deleted file mode 120000 index 7b299d9..0000000 --- a/public/assets +++ /dev/null @@ -1 +0,0 @@ -../front/assets \ No newline at end of file diff --git a/public/front b/public/front deleted file mode 120000 index c1394c9..0000000 --- a/public/front +++ /dev/null @@ -1 +0,0 @@ -../front \ No newline at end of file diff --git a/sql/database.php b/sql/database.php deleted file mode 100644 index 8f5aa9d..0000000 --- a/sql/database.php +++ /dev/null @@ -1,27 +0,0 @@ -query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")->fetchColumn() > 0; - - if ($database_exists) { - return $pdo; - } - - foreach (scandir(__DIR__) as $file) { - if (preg_match("/.*\.sql$/i", $file)) { - $content = file_get_contents(__DIR__ . "/" . $file); - - $pdo->exec($content); - } - } - - - return $pdo; -} diff --git a/src/Api/API.php b/src/Api/API.php deleted file mode 100644 index da00749..0000000 --- a/src/Api/API.php +++ /dev/null @@ -1,55 +0,0 @@ -getCode()); - - foreach ($response->getHeaders() as $header => $value) { - header("$header: $value"); - } - - if ($response instanceof JsonHttpResponse) { - header('Content-type: application/json'); - echo $response->getJson(); - } elseif (get_class($response) != HttpResponse::class) { - throw new Exception("API returned unknown Http Response"); - } - } - - - /** - * @param array $match - * @param callable $tryGetAuthorization function to return an authorisation object for the given action (if required) - * @return HttpResponse - * @throws Exception - */ - public static function handleMatch(array $match, callable $tryGetAuthorization): HttpResponse { - if (!$match) { - return new JsonHttpResponse([ValidationFail::notFound("not found")]); - } - - $action = $match['target']; - if (!$action instanceof Action) { - throw new Exception("routed action is not an AppAction object."); - } - - $auth = null; - - if ($action->isAuthRequired()) { - $auth = call_user_func($tryGetAuthorization); - if ($auth == null) { - return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")]); - } - } - - return $action->run($match['params'], $auth); - } -} diff --git a/src/Api/Controller/APIAuthController.php b/src/Api/Controller/APIAuthController.php deleted file mode 100644 index fc0eef6..0000000 --- a/src/Api/Controller/APIAuthController.php +++ /dev/null @@ -1,44 +0,0 @@ -model = $model; - } - - - /** - * From given email address and password, authenticate the user and respond with its authorization token. - * @return HttpResponse - */ - public function authorize(): HttpResponse { - return Control::runChecked([ - "email" => [Validators::email(), Validators::lenBetween(5, 256)], - "password" => [Validators::lenBetween(6, 256)], - ], function (HttpRequest $req) { - $failures = []; - $account = $this->model->login($req["email"], $req["password"], $failures); - - if (!empty($failures)) { - return new JsonHttpResponse($failures, HttpCodes::UNAUTHORIZED); - } - - return new JsonHttpResponse(["authorization" => $account->getToken()]); - }); - } - -} diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php deleted file mode 100644 index a116add..0000000 --- a/src/Api/Controller/APITacticController.php +++ /dev/null @@ -1,65 +0,0 @@ -model = $model; - } - - /** - * update name of tactic, specified by tactic identifier, given in url. - * @param int $tactic_id - * @param Account $account - * @return HttpResponse - */ - public function updateName(int $tactic_id, Account $account): HttpResponse { - return Control::runChecked([ - "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()], - ], function (HttpRequest $request) use ($tactic_id, $account) { - - $failures = $this->model->updateName($tactic_id, $request["name"], $account->getUser()->getId()); - - if (!empty($failures)) { - //TODO find a system to handle Unauthorized error codes more easily from failures. - return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST); - } - - return HttpResponse::fromCode(HttpCodes::OK); - }); - } - - /** - * @param int $id - * @param Account $account - * @return HttpResponse - */ - public function saveContent(int $id, Account $account): HttpResponse { - return Control::runChecked([ - "content" => [], - ], function (HttpRequest $req) use ($id) { - if ($fail = $this->model->updateContent($id, json_encode($req["content"]))) { - return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); - } - return HttpResponse::fromCode(HttpCodes::OK); - }); - } -} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..f28f8a9 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,67 @@ +import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom" + +import { Header } from "./pages/template/Header.tsx" +import "./style/app.css" +import { lazy } from "react" +import { BASE } from "./Constants.ts" + +const HomePage = lazy(() => import("./pages/HomePage.tsx")) +const LoginPage = lazy(() => import("./pages/LoginPage.tsx")) +const RegisterPage = lazy(() => import("./pages/RegisterPage.tsx")) +const NotFoundPage = lazy(() => import("./pages/404.tsx")) +const CreateTeamPage = lazy(() => import("./pages/CreateTeamPage.tsx")) +const TeamPanelPage = lazy(() => import("./pages/TeamPanel.tsx")) +const NewTacticPage = lazy(() => import("./pages/NewTacticPage.tsx")) +const Editor = lazy(() => import("./pages/Editor.tsx")) + +export default function App() { + return ( +
+ + + + + } /> + } /> + + }> + } /> + } /> + + } + /> + } + /> + } + /> + } + /> + } + /> + + } /> + + + +
+ ) +} + +function AppLayout() { + return ( + <> +
+ + + ) +} diff --git a/src/App/App.php b/src/App/App.php deleted file mode 100644 index cd3c293..0000000 --- a/src/App/App.php +++ /dev/null @@ -1,92 +0,0 @@ -getCode()); - - foreach ($response->getHeaders() as $header => $value) { - header("$header: $value"); - } - - if ($response instanceof ViewHttpResponse) { - self::renderView($response, $twigSupplier); - } elseif ($response instanceof JsonHttpResponse) { - header('Content-type: application/json'); - echo $response->getJson(); - } - } - - /** - * renders (prints out) given ViewHttpResponse to the client - * @param ViewHttpResponse $response - * @param callable(): Environment $twigSupplier - * @return void - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - */ - private static function renderView(ViewHttpResponse $response, callable $twigSupplier): void { - $file = $response->getFile(); - $args = $response->getArguments(); - - switch ($response->getViewKind()) { - case ViewHttpResponse::REACT_VIEW: - send_react_front($file, $args); - break; - case ViewHttpResponse::TWIG_VIEW: - try { - $twig = call_user_func($twigSupplier); - $twig->display($file, $args); - } catch (RuntimeError|SyntaxError|LoaderError $e) { - http_response_code(500); - echo "There was an error rendering your view, please refer to an administrator.\nlogs date: " . date("YYYD, d M Y H:i:s"); - throw $e; - } - break; - } - } - - /** - * run a user action, and return the generated response - * @param string $authRoute the route towards an authentication page to response with a redirection - * if the run action requires auth but session does not contain a logged-in account. - * @param Action $action - * @param mixed[] $params - * @param MutableSessionHandle $session - * @return HttpResponse - */ - public static function runAction(string $authRoute, Action $action, array $params, MutableSessionHandle $session): HttpResponse { - if ($action->isAuthRequired()) { - $account = $session->getAccount(); - if ($account == null) { - // put in the session the initial url the user wanted to get - $session->setInitialTarget($_SERVER['REQUEST_URI']); - return HttpResponse::redirect($authRoute); - } - } - - return $action->run($params, $session); - } - -} diff --git a/src/App/Control.php b/src/App/Control.php deleted file mode 100644 index 5c2fe0f..0000000 --- a/src/App/Control.php +++ /dev/null @@ -1,48 +0,0 @@ - $schema an array of `fieldName => Validators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * THe callback must accept an HttpRequest, and return an HttpResponse object. - * @return HttpResponse - */ - public static function runChecked(array $schema, callable $run): HttpResponse { - $request_body = file_get_contents('php://input'); - $payload_obj = json_decode($request_body); - if (!$payload_obj instanceof \stdClass) { - $fail = new ValidationFail("bad-payload", "request body is not a valid json object"); - return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST); - } - $payload = get_object_vars($payload_obj); - return self::runCheckedFrom($payload, $schema, $run); - } - - /** - * Runs given callback, if the given request data array validates the given schema. - * @param array $data the request's data array. - * @param array $schema an array of `fieldName => Validators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * THe callback must accept an HttpRequest, and return an HttpResponse object. - * @return HttpResponse - */ - public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { - $fails = []; - $request = HttpRequest::from($data, $fails, $schema); - - if (!empty($fails)) { - return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST); - } - - return call_user_func_array($run, [$request]); - } -} diff --git a/src/App/Controller/TeamController.php b/src/App/Controller/TeamController.php deleted file mode 100644 index 4ab3fd7..0000000 --- a/src/App/Controller/TeamController.php +++ /dev/null @@ -1,246 +0,0 @@ -model = $model; - } - - /** - * @param SessionHandle $session - * @return ViewHttpResponse the team creation panel - */ - public function displayCreateTeam(SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("insert_team.html.twig", []); - } - - /** - * @param SessionHandle $session - * @return ViewHttpResponse the team panel to delete a member - */ - public function displayDeleteMember(SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("delete_member.html.twig", []); - } - - /** - * create a new team from given request name, mainColor, secondColor and picture url - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function submitTeam(array $request, SessionHandle $session): HttpResponse { - $failures = []; - $request = HttpRequest::from($request, $failures, [ - "name" => [Validators::lenBetween(1, 32), Validators::nameWithSpaces()], - "main_color" => [Validators::hexColor()], - "second_color" => [Validators::hexColor()], - "picture" => [Validators::isURL()], - ]); - if (!empty($failures)) { - $badFields = []; - foreach ($failures as $e) { - if ($e instanceof FieldValidationFail) { - $badFields[] = $e->getFieldName(); - } - } - return ViewHttpResponse::twig('insert_team.html.twig', ['bad_fields' => $badFields]); - } - $teamId = $this->model->createTeam($request['name'], $request['picture'], $request['main_color'], $request['second_color']); - $this->model->addMember($session->getAccount()->getUser()->getEmail(), $teamId, 'COACH'); - return HttpResponse::redirect('/team/' . $teamId); - } - - /** - * @param SessionHandle $session - * @return ViewHttpResponse the panel to search a team by its name - */ - public function displayListTeamByName(SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("list_team_by_name.html.twig", []); - } - - /** - * returns a view that contains all the teams description whose name matches the given name needle. - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function listTeamByName(array $request, SessionHandle $session): HttpResponse { - $errors = []; - $request = HttpRequest::from($request, $errors, [ - "name" => [Validators::lenBetween(1, 32), Validators::nameWithSpaces()], - ]); - - if (!empty($errors) && $errors[0] instanceof FieldValidationFail) { - $badField = $errors[0]->getFieldName(); - return ViewHttpResponse::twig('list_team_by_name.html.twig', ['bad_field' => $badField]); - } - - $teams = $this->model->listByName($request['name'], $session->getAccount()->getUser()->getId()); - - if (empty($teams)) { - return ViewHttpResponse::twig('display_teams.html.twig', []); - } - return ViewHttpResponse::twig('display_teams.html.twig', ['teams' => $teams]); - } - - /** - * Delete a team with its id - * @param int $id - * @param SessionHandle $session - * @return HttpResponse - */ - public function deleteTeamById(int $id, SessionHandle $session): HttpResponse { - $a = $session->getAccount(); - $ret = $this->model->deleteTeam($a->getUser()->getEmail(), $id); - if($ret != 0) { - return ViewHttpResponse::twig('display_team.html.twig', ['notDeleted' => true]); - } - return HttpResponse::redirect('/'); - } - - /** - * Display a team with its id - * @param int $id - * @param SessionHandle $session - * @return ViewHttpResponse a view that displays given team information - */ - public function displayTeam(int $id, SessionHandle $session): ViewHttpResponse { - $result = $this->model->getTeam($id, $session->getAccount()->getUser()->getId()); - if($result == null) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $role = $this->model->isCoach($id, $session->getAccount()->getUser()->getEmail()); - - return ViewHttpResponse::react( - 'views/TeamPanel.tsx', - [ - 'team' => [ - "info" => $result->getInfo(), - "members" => $result->listMembers(), - ], - 'isCoach' => $role, - 'currentUserId' => $session->getAccount()->getUser()->getId()] - ); - } - - /** - * @param int $idTeam - * @param SessionHandle $session - * @return ViewHttpResponse the team panel to add a member - */ - public function displayAddMember(int $idTeam, SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("add_member.html.twig", ['idTeam' => $idTeam]); - } - - /** - * add a member to a team - * @param int $idTeam - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function addMember(int $idTeam, array $request, SessionHandle $session): HttpResponse { - $errors = []; - if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $request = HttpRequest::from($request, $errors, [ - "email" => [Validators::email(), Validators::lenBetween(5, 256)], - ]); - if(!empty($errors)) { - return ViewHttpResponse::twig('add_member.html.twig', ['badEmail' => true,'idTeam' => $idTeam]); - } - $ret = $this->model->addMember($request['email'], $idTeam, $request['role']); - - switch($ret) { - case -1: - return ViewHttpResponse::twig('add_member.html.twig', ['notFound' => true,'idTeam' => $idTeam]); - case -2: - return ViewHttpResponse::twig('add_member.html.twig', ['alreadyExisting' => true,'idTeam' => $idTeam]); - default: - return HttpResponse::redirect('/team/' . $idTeam); - } - } - - /** - * remove a member from a team with their ids - * @param int $idTeam - * @param int $idMember - * @param SessionHandle $session - * @return HttpResponse - */ - public function deleteMember(int $idTeam, int $idMember, SessionHandle $session): HttpResponse { - if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $teamId = $this->model->deleteMember($idMember, $idTeam); - if($teamId == -1 || $session->getAccount()->getUser()->getId() == $idMember) { - return HttpResponse::redirect('/'); - } - return $this->displayTeam($teamId, $session); - } - - /** - * @param int $idTeam - * @param SessionHandle $session - * @return ViewHttpResponse - */ - public function displayEditTeam(int $idTeam, SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("edit_team.html.twig", ['team' => $this->model->getTeam($idTeam, $session->getAccount()->getUser()->getId())]); - } - - /** - * @param int $idTeam - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function editTeam(int $idTeam, array $request, SessionHandle $session): HttpResponse { - if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $failures = []; - $request = HttpRequest::from($request, $failures, [ - "name" => [Validators::lenBetween(1, 32), Validators::nameWithSpaces()], - "main_color" => [Validators::hexColor()], - "second_color" => [Validators::hexColor()], - "picture" => [Validators::isURL()], - ]); - if (!empty($failures)) { - $badFields = []; - foreach ($failures as $e) { - if ($e instanceof FieldValidationFail) { - $badFields[] = $e->getFieldName(); - } - } - return ViewHttpResponse::twig('edit_team.html.twig', ['bad_fields' => $badFields]); - } - $this->model->editTeam($idTeam, $request['name'], $request['picture'], $request['main_color'], $request['second_color']); - return HttpResponse::redirect('/team/' . $idTeam); - } -} diff --git a/src/App/Controller/VisualizerController.php b/src/App/Controller/VisualizerController.php deleted file mode 100644 index 946f6d0..0000000 --- a/src/App/Controller/VisualizerController.php +++ /dev/null @@ -1,39 +0,0 @@ -tacticModel = $tacticModel; - } - - /** - * Opens a visualisation page for the tactic specified by its identifier in the url. - * @param int $id - * @param SessionHandle $session - * @return HttpResponse - */ - public function openVisualizer(int $id, SessionHandle $session): HttpResponse { - $tactic = $this->tacticModel->get($id); - - $failure = TacticValidator::validateAccess($id, $tactic, $session->getAccount()->getUser()->getId()); - - if ($failure != null) { - return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND); - } - - return ViewHttpResponse::react("views/Visualizer.tsx", ["name" => $tactic->getName()]); - } -} diff --git a/src/App/Session/MutableSessionHandle.php b/src/App/Session/MutableSessionHandle.php deleted file mode 100644 index 14871b6..0000000 --- a/src/App/Session/MutableSessionHandle.php +++ /dev/null @@ -1,22 +0,0 @@ -getOwnerId() != $ownerId) { - return ValidationFail::unauthorized("Vous ne pouvez pas accéder à cette tactique."); - } - return null; - } - -} diff --git a/src/App/ViewHttpResponse.php b/src/App/ViewHttpResponse.php deleted file mode 100644 index dfbd1da..0000000 --- a/src/App/ViewHttpResponse.php +++ /dev/null @@ -1,75 +0,0 @@ - View arguments - */ - private array $arguments; - /** - * @var int Kind of view, see {@link self::TWIG_VIEW} and {@link self::REACT_VIEW} - */ - private int $kind; - - /** - * @param int $code - * @param int $kind - * @param string $file - * @param array $arguments - */ - private function __construct(int $kind, string $file, array $arguments, int $code = HttpCodes::OK) { - parent::__construct($code, []); - $this->kind = $kind; - $this->file = $file; - $this->arguments = $arguments; - } - - public function getViewKind(): int { - return $this->kind; - } - - public function getFile(): string { - return $this->file; - } - - /** - * @return array - */ - public function getArguments(): array { - return $this->arguments; - } - - /** - * Create a twig view response - * @param string $file - * @param array $arguments - * @param int $code - * @return ViewHttpResponse - */ - public static function twig(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { - return new ViewHttpResponse(self::TWIG_VIEW, $file, $arguments, $code); - } - - /** - * Create a react view response - * @param string $file - * @param array $arguments - * @param int $code - * @return ViewHttpResponse - */ - public static function react(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { - return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code); - } - -} diff --git a/src/App/Views/account_settings.twig b/src/App/Views/account_settings.twig deleted file mode 100644 index 04d7437..0000000 --- a/src/App/Views/account_settings.twig +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - Paramètres - - - - - - - -

Paramètres

- - \ No newline at end of file diff --git a/src/App/Views/add_member.html.twig b/src/App/Views/add_member.html.twig deleted file mode 100644 index cfee16d..0000000 --- a/src/App/Views/add_member.html.twig +++ /dev/null @@ -1,118 +0,0 @@ - - - - - Ajouter un membre - - - -
-

IQBall

-
- -
-

Ajouter un membre à votre équipe

-
-
- - - {% if badEmail %} -

Email invalide

- {% endif %} - {%if notFound %} -

Cette personne n'a pas été trouvé

- {% endif %} - {% if alreadyExisting %} -

Cette personne est déjà dans l'équipe

- {% endif %} - - -
- Rôle du membre dans l'équipe : -
- - -
-
- - -
-
- -
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/App/Views/delete_member.html.twig b/src/App/Views/delete_member.html.twig deleted file mode 100644 index 3fa5ccd..0000000 --- a/src/App/Views/delete_member.html.twig +++ /dev/null @@ -1,73 +0,0 @@ - - - - - Ajouter un membre - - - - -
-

Supprimez un membre de votre équipe

-
-
- - - - -
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/App/Views/display_auth_confirm.html.twig b/src/App/Views/display_auth_confirm.html.twig deleted file mode 100644 index 60c63b2..0000000 --- a/src/App/Views/display_auth_confirm.html.twig +++ /dev/null @@ -1,46 +0,0 @@ - - - - - Profil Utilisateur - - - - - - - - \ No newline at end of file diff --git a/src/App/Views/display_login.html.twig b/src/App/Views/display_login.html.twig deleted file mode 100644 index c609537..0000000 --- a/src/App/Views/display_login.html.twig +++ /dev/null @@ -1,107 +0,0 @@ - - - - Connexion - - - - - -
-

Se connecter

-
-
- - {% for name in fails %} - - {% endfor %} - - - - - - Vous n'avez pas de compte ? -

-
- -
- -
- - \ No newline at end of file diff --git a/src/App/Views/display_register.html.twig b/src/App/Views/display_register.html.twig deleted file mode 100644 index 38bdb43..0000000 --- a/src/App/Views/display_register.html.twig +++ /dev/null @@ -1,116 +0,0 @@ - - - - S'enregistrer - - - - - -
-

S'enregistrer

-
-
- - {% for name in fails %} - - {% endfor %} - - - - - - - - - - Vous avez déjà un compte ? - -
-
- -
-
-
- - - - \ No newline at end of file diff --git a/src/App/Views/display_results.html.twig b/src/App/Views/display_results.html.twig deleted file mode 100644 index a33546b..0000000 --- a/src/App/Views/display_results.html.twig +++ /dev/null @@ -1,18 +0,0 @@ - - - - Twig view - - - -

Hello world

- - -{% for v in results %} -

username: {{ v.name }}

-

description: {{ v.description }}

-{% endfor %} - - - - \ No newline at end of file diff --git a/src/App/Views/display_team.html.twig b/src/App/Views/display_team.html.twig deleted file mode 100644 index 8928e84..0000000 --- a/src/App/Views/display_team.html.twig +++ /dev/null @@ -1,109 +0,0 @@ - - - - - Twig view - - - -
-

IQBall

-
- -
- {% if notDeleted %} - -

Cette équipe ne peut être supprimée.

-
- {% endif %} -{% if team is defined %} -
-
-

{{ team.getInfo().getName() }}

- -
-
-

Couleur principale :

-
-
-

Couleur secondaire :

-
-
-
- {% if isCoach %} - - - {% endif %} - {% for m in team.listMembers() %} -
-

{{ m.getUserId() }}

- {% if m.getRole().isCoach() %} -

: Coach

- {% else %} -

: Joueur

- {% endif %} -
- {% endfor %} -
-{% else %} -
-

Cette équipe ne peut être affichée

-
-{% endif %} -
- - \ No newline at end of file diff --git a/src/App/Views/display_teams.html.twig b/src/App/Views/display_teams.html.twig deleted file mode 100644 index 3e3ab12..0000000 --- a/src/App/Views/display_teams.html.twig +++ /dev/null @@ -1,61 +0,0 @@ - - - - - Twig view - - - -
-

IQBall

-
-
-{% if teams is empty %} -

Aucune équipe n'a été trouvée

-
-

Chercher une équipe

-
-
- - -
-
- -
-
-
-{% else %} - {% for t in teams %} -
-

Nom de l'équipe : {{ t.getName() }}

- logo de l'équipe -
- {% endfor %} -{% endif %} -
- - \ No newline at end of file diff --git a/src/App/Views/edit_team.html.twig b/src/App/Views/edit_team.html.twig deleted file mode 100644 index 409d71a..0000000 --- a/src/App/Views/edit_team.html.twig +++ /dev/null @@ -1,81 +0,0 @@ - - - - - Insertion view - - - - -
-

Modifier votre équipe

-
-
- - - - - - - - -
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/App/Views/error.html.twig b/src/App/Views/error.html.twig deleted file mode 100644 index bf90319..0000000 --- a/src/App/Views/error.html.twig +++ /dev/null @@ -1,57 +0,0 @@ - - - - - Error - - - - -

IQBall

- -{% for fail in failures %} -

{{ fail.getKind() }} : {{ fail.getMessage() }}

-{% endfor %} - - - - - - \ No newline at end of file diff --git a/src/App/Views/home.twig b/src/App/Views/home.twig deleted file mode 100644 index 0fc426a..0000000 --- a/src/App/Views/home.twig +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - Page d'accueil - - - - - - -
-

IQ Ball

-
- Account logo -

Mon profil -

-

-
- -

Mes équipes

- - - -{% if recentTeam != null %} - {% for team in recentTeam %} -
-

{{ team.name }}

-
- {% endfor %} -{% else %} -

Aucune équipe créée !

-{% endif %} - -

Mes strategies

- - - -{% if recentTactic != null %} - {% for tactic in recentTactic %} -
-

{{ tactic.id }} - {{ tactic.name }} - {{ tactic.creation_date }}

- -
- {% endfor %} -{% else %} -

Aucune tactique créée !

-{% endif %} - - - \ No newline at end of file diff --git a/src/App/Views/insert_team.html.twig b/src/App/Views/insert_team.html.twig deleted file mode 100644 index 0c10114..0000000 --- a/src/App/Views/insert_team.html.twig +++ /dev/null @@ -1,81 +0,0 @@ - - - - - Insertion view - - - - -
-

Créer une équipe

-
-
- - - - - - - - -
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/App/Views/list_team_by_name.html.twig b/src/App/Views/list_team_by_name.html.twig deleted file mode 100644 index 092a149..0000000 --- a/src/App/Views/list_team_by_name.html.twig +++ /dev/null @@ -1,79 +0,0 @@ - - - - - Insertion view - - - -
-

IQBall

-
-
-

Chercher une équipe

-
-
- - -
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/App/react-display-file.php b/src/App/react-display-file.php deleted file mode 100755 index 2dfcd11..0000000 --- a/src/App/react-display-file.php +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - "> - - - - - Document - - - - - - - -
- - - - - - - - - \ No newline at end of file diff --git a/src/App/react-display.php b/src/App/react-display.php deleted file mode 100644 index 5baf41b..0000000 --- a/src/App/react-display.php +++ /dev/null @@ -1,13 +0,0 @@ - $arguments arguments to pass to the rendered react component - * The arguments must be a json-encodable key/value dictionary. - * @return void - */ -function send_react_front(string $url, array $arguments) { - // the $url and $argument values are used into the included file - require_once "react-display-file.php"; -} diff --git a/front/Constants.ts b/src/Constants.ts similarity index 64% rename from front/Constants.ts rename to src/Constants.ts index a37cc16..4f77f36 100644 --- a/front/Constants.ts +++ b/src/Constants.ts @@ -6,4 +6,4 @@ export const API = import.meta.env.VITE_API_ENDPOINT /** * This constant defines the base app's endpoint. */ -export const BASE = import.meta.env.VITE_BASE \ No newline at end of file +export const BASE = import.meta.env.BASE_URL.slice(0, import.meta.env.BASE_URL.length - 1) diff --git a/src/Core/Action.php b/src/Core/Action.php deleted file mode 100644 index 35721c1..0000000 --- a/src/Core/Action.php +++ /dev/null @@ -1,58 +0,0 @@ -action = $action; - $this->isAuthRequired = $isAuthRequired; - } - - public function isAuthRequired(): bool { - return $this->isAuthRequired; - } - - /** - * Runs an action - * @param mixed[] $params - * @param S $session - * @return HttpResponse - */ - public function run(array $params, $session): HttpResponse { - $params = array_values($params); - $params[] = $session; - return call_user_func_array($this->action, $params); - } - - /** - * @param callable(mixed[], S): HttpResponse $action - * @return Action an action that does not require to have an authorization. - */ - public static function noAuth(callable $action): Action { - return new Action($action, false); - } - - /** - * @param callable(mixed[], S): HttpResponse $action - * @return Action an action that does require to have an authorization. - */ - public static function auth(callable $action): Action { - return new Action($action, true); - } -} diff --git a/src/Core/Data/Account.php b/src/Core/Data/Account.php deleted file mode 100755 index 01f5406..0000000 --- a/src/Core/Data/Account.php +++ /dev/null @@ -1,41 +0,0 @@ -token = $token; - $this->user = $user; - } - - public function getToken(): string { - return $this->token; - } - - /** - * @return User - */ - public function getUser(): User { - return $this->user; - } - -} diff --git a/src/Core/Data/CourtType.php b/src/Core/Data/CourtType.php deleted file mode 100755 index caad45c..0000000 --- a/src/Core/Data/CourtType.php +++ /dev/null @@ -1,61 +0,0 @@ - self::COURT_HALF) { - throw new InvalidArgumentException("Valeur du rôle invalide"); - } - $this->value = $val; - } - - public static function plain(): CourtType { - return new CourtType(CourtType::COURT_PLAIN); - } - - public static function half(): CourtType { - return new CourtType(CourtType::COURT_HALF); - } - - public function name(): string { - switch ($this->value) { - case self::COURT_HALF: - return "HALF"; - case self::COURT_PLAIN: - return "PLAIN"; - } - die("unreachable"); - } - - public static function fromName(string $name): ?CourtType { - switch ($name) { - case "HALF": - return CourtType::half(); - case "PLAIN": - return CourtType::plain(); - default: - return null; - } - } - - public function isPlain(): bool { - return ($this->value == self::COURT_PLAIN); - } - - public function isHalf(): bool { - return ($this->value == self::COURT_HALF); - } - -} diff --git a/src/Core/Data/Member.php b/src/Core/Data/Member.php deleted file mode 100755 index 30e4202..0000000 --- a/src/Core/Data/Member.php +++ /dev/null @@ -1,57 +0,0 @@ -user = $user; - $this->teamId = $teamId; - $this->role = $role; - } - - /** - * @return string - */ - public function getRole(): string { - return $this->role; - } - - /** - * @return int - */ - public function getTeamId(): int { - return $this->teamId; - } - - /** - * @return User - */ - public function getUser(): User { - return $this->user; - } - - - public function jsonSerialize() { - return get_object_vars($this); - } -} diff --git a/src/Core/Data/TacticInfo.php b/src/Core/Data/TacticInfo.php deleted file mode 100644 index c3b8667..0000000 --- a/src/Core/Data/TacticInfo.php +++ /dev/null @@ -1,62 +0,0 @@ -id = $id; - $this->name = $name; - $this->ownerId = $ownerId; - $this->creationDate = $creationDate; - $this->courtType = $type; - $this->content = $content; - } - - /** - * @return string - */ - public function getContent(): string { - return $this->content; - } - - public function getId(): int { - return $this->id; - } - - public function getName(): string { - return $this->name; - } - - /** - * @return int - */ - public function getOwnerId(): int { - return $this->ownerId; - } - - public function getCourtType(): CourtType { - return $this->courtType; - } - - /** - * @return int - */ - public function getCreationDate(): int { - return $this->creationDate; - } -} diff --git a/src/Core/Data/Team.php b/src/Core/Data/Team.php deleted file mode 100755 index 7adeb49..0000000 --- a/src/Core/Data/Team.php +++ /dev/null @@ -1,38 +0,0 @@ -info = $info; - $this->members = $members; - } - - public function getInfo(): TeamInfo { - return $this->info; - } - - /** - * @return Member[] - */ - public function listMembers(): array { - return $this->members; - } - - public function jsonSerialize() { - return get_object_vars($this); - } - - -} diff --git a/src/Core/Data/TeamInfo.php b/src/Core/Data/TeamInfo.php deleted file mode 100644 index 0f741fe..0000000 --- a/src/Core/Data/TeamInfo.php +++ /dev/null @@ -1,53 +0,0 @@ -id = $id; - $this->name = $name; - $this->picture = $picture; - $this->mainColor = $mainColor; - $this->secondColor = $secondColor; - } - - - public function getId(): int { - return $this->id; - } - - public function getName(): string { - return $this->name; - } - - public function getPicture(): string { - return $this->picture; - } - - public function getMainColor(): string { - return $this->mainColor; - } - - public function getSecondColor(): string { - return $this->secondColor; - } - - public function jsonSerialize() { - return get_object_vars($this); - } - - -} diff --git a/src/Core/Data/User.php b/src/Core/Data/User.php deleted file mode 100644 index 71e0dd1..0000000 --- a/src/Core/Data/User.php +++ /dev/null @@ -1,72 +0,0 @@ -email = $email; - $this->name = $name; - $this->id = $id; - $this->profilePicture = $profilePicture; - } - - /** - * @return string - */ - public function getEmail(): string { - return $this->email; - } - - /** - * @return string - */ - public function getName(): string { - return $this->name; - } - - /** - * @return int - */ - public function getId(): int { - return $this->id; - } - - /** - * @return string - */ - public function getProfilePicture(): string { - return $this->profilePicture; - } - - public function jsonSerialize() { - return get_object_vars($this); - } -} diff --git a/src/Core/Gateway/MemberGateway.php b/src/Core/Gateway/MemberGateway.php deleted file mode 100644 index a5116e8..0000000 --- a/src/Core/Gateway/MemberGateway.php +++ /dev/null @@ -1,100 +0,0 @@ -con = $con; - } - - /** - * insert member to a team - * @param int $idTeam - * @param int $userId - * @param string $role - * @return void - */ - public function insert(int $idTeam, int $userId, string $role): void { - $this->con->exec( - "INSERT INTO Member(id_team, id_user, role) VALUES (:id_team, :id_user, :role)", - [ - ":id_team" => [$idTeam, PDO::PARAM_INT], - ":id_user" => [$userId, PDO::PARAM_INT], - ":role" => [$role, PDO::PARAM_STR], - ] - ); - } - - /** - * @param int $teamId - * @return Member[] - */ - public function getMembersOfTeam(int $teamId): array { - $rows = $this->con->fetch( - "SELECT a.id,a.email,a.username,a.profilePicture,m.role FROM Account a,team t,Member m WHERE t.id = :id AND m.id_team = t.id AND m.id_user = a.id", - [ - ":id" => [$teamId, PDO::PARAM_INT], - ] - ); - return array_map(fn($row) => new Member(new User($row['email'], $row['username'], $row['id'], $row['profilePicture']), $teamId, $row['role']), $rows); - } - - /** - * remove member from given team - * @param int $idTeam - * @param int $idMember - * @return void - */ - public function remove(int $idTeam, int $idMember): void { - $this->con->exec( - "DELETE FROM Member WHERE id_team = :id_team AND id_user = :id_user", - [ - ":id_team" => [$idTeam, PDO::PARAM_INT], - ":id_user" => [$idMember, PDO::PARAM_INT], - ] - ); - } - - /** - * @param string $email - * @param int $idTeam - * @return bool - */ - public function isCoach(string $email, int $idTeam): bool { - $result = $this->con->fetch( - "SELECT role FROM Member WHERE id_team=:team AND id_user = (SELECT id FROM Account WHERE email=:email)", - [ - "team" => [$idTeam, PDO::PARAM_INT], - "email" => [$email, PDO::PARAM_STR], - ] - )[0]['role']; - - return $result == 'COACH'; - } - - /** - * @param int $idTeam - * @param int $idCurrentUser - * @return bool - */ - public function isMemberOfTeam(int $idTeam, int $idCurrentUser): bool { - $result = $this->con->fetch( - "SELECT id_user FROM Member WHERE id_team = :team AND id_user = :user", - [ - "team" => [$idTeam, PDO::PARAM_INT], - "user" => [$idCurrentUser, PDO::PARAM_INT], - ] - ); - return !empty($result); - } -} diff --git a/src/Core/Gateway/TacticInfoGateway.php b/src/Core/Gateway/TacticInfoGateway.php deleted file mode 100644 index 08302c9..0000000 --- a/src/Core/Gateway/TacticInfoGateway.php +++ /dev/null @@ -1,133 +0,0 @@ -con = $con; - } - - /** - * get tactic information from given identifier - * @param int $id - * @return TacticInfo|null - */ - public function get(int $id): ?TacticInfo { - $res = $this->con->fetch( - "SELECT * FROM Tactic WHERE id = :id", - [":id" => [$id, PDO::PARAM_INT]] - ); - - if (!isset($res[0])) { - return null; - } - - $row = $res[0]; - - $type = CourtType::fromName($row['court_type']); - return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"], $type, $row['content']); - } - - - /** - * Return the nb last tactics created - * - * @param integer $nb - * @return array> - */ - public function getLast(int $nb, int $ownerId): ?array { - $res = $this->con->fetch( - "SELECT * - FROM Tactic - WHERE owner = :ownerId - ORDER BY creation_date DESC - LIMIT :nb", - [ - ":ownerId" => [$ownerId, PDO::PARAM_INT],":nb" => [$nb, PDO::PARAM_INT], - ] - ); - if (count($res) == 0) { - return []; - } - return $res; - } - - /** - * Get all the tactics of the owner - * - * @return array> - */ - public function getAll(int $ownerId): ?array { - $res = $this->con->fetch( - "SELECT * - FROM Tactic - WHERE owner = :ownerId - ORDER BY name DESC", - [ - ":ownerId" => [$ownerId, PDO::PARAM_INT], - ] - ); - if (count($res) == 0) { - return []; - } - return $res; - } - - /** - * @param string $name - * @param int $owner - * @param CourtType $type - * @return int inserted tactic id - */ - public function insert(string $name, int $owner, CourtType $type): int { - $this->con->exec( - "INSERT INTO Tactic(name, owner, court_type) VALUES(:name, :owner, :court_type)", - [ - ":name" => [$name, PDO::PARAM_STR], - ":owner" => [$owner, PDO::PARAM_INT], - ":court_type" => [$type->name(), PDO::PARAM_STR], - ] - ); - return intval($this->con->lastInsertId()); - } - - /** - * update name of given tactic identifier - * @param int $id - * @param string $name - * @return bool - */ - public function updateName(int $id, string $name): bool { - $stmnt = $this->con->prepare("UPDATE Tactic SET name = :name WHERE id = :id"); - $stmnt->execute([ - ":name" => $name, - ":id" => $id, - ]); - return $stmnt->rowCount() == 1; - } - - /*** - * Updates a given tactics content - * @param int $id - * @param string $json - * @return bool - */ - public function updateContent(int $id, string $json): bool { - $stmnt = $this->con->prepare("UPDATE Tactic SET content = :content WHERE id = :id"); - $stmnt->execute([ - ":content" => $json, - ":id" => $id, - ]); - return $stmnt->rowCount() == 1; - } -} diff --git a/src/Core/Http/HttpCodes.php b/src/Core/Http/HttpCodes.php deleted file mode 100644 index 1903f0c..0000000 --- a/src/Core/Http/HttpCodes.php +++ /dev/null @@ -1,18 +0,0 @@ - - * */ -class HttpRequest implements ArrayAccess { - /** - * @var array - */ - private array $data; - - /** - * @param array $data - */ - private function __construct(array $data) { - $this->data = $data; - } - - /** - * Creates a new HttpRequest instance, and ensures that the given request data validates the given schema. - * This is a simple function that only supports flat schemas (non-composed, the data must only be a k/v array pair.) - * @param array $request the request's data - * @param array $fails a reference to a failure array, that will contain the reported validation failures. - * @param array $schema the schema to satisfy. a schema is a simple array with a string key (which is the top-level field name), and a set of validators - * @return HttpRequest|null the built HttpRequest instance, or null if a field is missing, or if any of the schema validator failed - */ - public static function from(array $request, array &$fails, array $schema): ?HttpRequest { - $failure = false; - foreach ($schema as $fieldName => $fieldValidators) { - if (!isset($request[$fieldName])) { - $fails[] = FieldValidationFail::missing($fieldName); - $failure = true; - continue; - } - $failure |= Validation::validate($request[$fieldName], $fieldName, $fails, ...$fieldValidators); - } - - if ($failure) { - return null; - } - return new HttpRequest($request); - } - - public function offsetExists($offset): bool { - return isset($this->data[$offset]); - } - - /** - * @param $offset - * @return mixed - */ - public function offsetGet($offset) { - return $this->data[$offset]; - } - - /** - * @param $offset - * @param $value - * @throws Exception - */ - public function offsetSet($offset, $value) { - throw new Exception("requests are immutable objects."); - } - - /** - * @param $offset - * @throws Exception - */ - public function offsetUnset($offset) { - throw new Exception("requests are immutable objects."); - } -} diff --git a/src/Core/Http/HttpResponse.php b/src/Core/Http/HttpResponse.php deleted file mode 100644 index c98a261..0000000 --- a/src/Core/Http/HttpResponse.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ - private array $headers; - private int $code; - - /** - * @param int $code - * @param array $headers - */ - public function __construct(int $code, array $headers) { - $this->code = $code; - $this->headers = $headers; - } - - public function getCode(): int { - return $this->code; - } - - /** - * @return array - */ - public function getHeaders(): array { - return $this->headers; - } - - /** - * @param int $code - * @return HttpResponse - */ - public static function fromCode(int $code): HttpResponse { - return new HttpResponse($code, []); - } - - /** - * @param string $url the url to redirect - * @param int $code only HTTP 3XX codes are accepted. - * @return HttpResponse a response that will redirect client to given url - */ - - public static function redirect(string $url, int $code = HttpCodes::FOUND): HttpResponse { - global $basePath; - return self::redirect_absolute($basePath . $url, $code); - } - - /** - * @param string $url the url to redirect - * @param int $code only HTTP 3XX codes are accepted. - * @return HttpResponse a response that will redirect client to given url - */ - - public static function redirect_absolute(string $url, int $code = HttpCodes::FOUND): HttpResponse { - if ($code < 300 || $code >= 400) { - throw new \InvalidArgumentException("given code is not a redirection http code"); - } - return new HttpResponse($code, ["Location" => $url]); - } - - -} diff --git a/src/Core/Http/JsonHttpResponse.php b/src/Core/Http/JsonHttpResponse.php deleted file mode 100644 index bb897f7..0000000 --- a/src/Core/Http/JsonHttpResponse.php +++ /dev/null @@ -1,28 +0,0 @@ -payload = $payload; - } - - public function getJson(): string { - $result = json_encode($this->payload); - if (!$result) { - throw new \RuntimeException("Given payload is not json encodable"); - } - - return $result; - } - -} diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php deleted file mode 100644 index 7057e7f..0000000 --- a/src/Core/Model/TacticModel.php +++ /dev/null @@ -1,113 +0,0 @@ -tactics = $tactics; - } - - /** - * creates a new empty tactic, with given name - * @param string $name - * @param int $ownerId - * @param CourtType $type - * @return TacticInfo - */ - public function makeNew(string $name, int $ownerId, CourtType $type): TacticInfo { - $id = $this->tactics->insert($name, $ownerId, $type); - return $this->tactics->get($id); - } - - /** - * creates a new empty tactic, with a default name - * @param int $ownerId - * @param CourtType $type - * @return TacticInfo|null - */ - public function makeNewDefault(int $ownerId, CourtType $type): ?TacticInfo { - return $this->makeNew(self::TACTIC_DEFAULT_NAME, $ownerId, $type); - } - - /** - * Tries to retrieve information about a tactic - * @param int $id tactic identifier - * @return TacticInfo|null or null if the identifier did not match a tactic - */ - public function get(int $id): ?TacticInfo { - return $this->tactics->get($id); - } - - /** - * Return the nb last tactics created - * - * @param integer $nb - * @return array> - */ - - /** - * Return the nb last tactics - * - * @param integer $nb - * @param integer $ownerId - * @return array> - */ - public function getLast(int $nb, int $ownerId): array { - return $this->tactics->getLast($nb, $ownerId); - } - - /** - * Get all the tactics of the owner - * - * @param integer $ownerId - * @return array> - */ - public function getAll(int $ownerId): ?array { - return $this->tactics->getAll($ownerId); - } - /** - * Update the name of a tactic - * @param int $id the tactic identifier - * @param string $name the new name to set - * @return ValidationFail[] failures, if any - */ - public function updateName(int $id, string $name, int $authId): array { - - $tactic = $this->tactics->get($id); - - if ($tactic == null) { - return [ValidationFail::notFound("Could not find tactic")]; - } - - if ($tactic->getOwnerId() != $authId) { - return [ValidationFail::unauthorized()]; - } - - if (!$this->tactics->updateName($id, $name)) { - return [ValidationFail::error("Could not update name")]; - } - return []; - } - - public function updateContent(int $id, string $json): ?ValidationFail { - if (!$this->tactics->updateContent($id, $json)) { - return ValidationFail::error("Could not update content"); - } - return null; - } - -} diff --git a/src/Core/Model/TeamModel.php b/src/Core/Model/TeamModel.php deleted file mode 100644 index 2bfe36e..0000000 --- a/src/Core/Model/TeamModel.php +++ /dev/null @@ -1,142 +0,0 @@ -teams = $gateway; - $this->members = $members; - $this->users = $users; - } - - /** - * Create a team - * @param string $name - * @param string $picture - * @param string $mainColor - * @param string $secondColor - * @return int - */ - public function createTeam(string $name, string $picture, string $mainColor, string $secondColor): int { - return $this->teams->insert($name, $picture, $mainColor, $secondColor); - } - - /** - * add a member to a team - * @param string $mail - * @param int $teamId - * @param string $role - * @return int - */ - public function addMember(string $mail, int $teamId, string $role): int { - $user = $this->users->getAccountFromMail($mail); - if($user == null) { - return -1; - } - if(!$this->members->isMemberOfTeam($teamId, $user->getUser()->getId())) { - $this->members->insert($teamId, $user->getUser()->getId(), $role); - return 1; - } - return -2; - } - - /** - * @param string $name - * @param int $id - * @return TeamInfo[] - */ - public function listByName(string $name, int $id): array { - return $this->teams->listByName($name, $id); - } - - /** - * @param int $idTeam - * @param int $idCurrentUser - * @return Team|null - */ - public function getTeam(int $idTeam, int $idCurrentUser): ?Team { - if(!$this->members->isMemberOfTeam($idTeam, $idCurrentUser)) { - return null; - } - $teamInfo = $this->teams->getTeamById($idTeam); - $members = $this->members->getMembersOfTeam($idTeam); - return new Team($teamInfo, $members); - } - - /** - * delete a member from given team identifier - * @param int $idMember - * @param int $teamId - * @return int - */ - public function deleteMember(int $idMember, int $teamId): int { - $this->members->remove($teamId, $idMember); - if(empty($this->members->getMembersOfTeam($teamId))) { - $this->teams->deleteTeam($teamId); - return -1; - } - return $teamId; - } - - /** - * Delete a team - * @param string $email - * @param int $idTeam - * @return int - */ - public function deleteTeam(string $email, int $idTeam): int { - if($this->members->isCoach($email, $idTeam)) { - $this->teams->deleteTeam($idTeam); - return 0; - } - return -1; - } - - /** - * Verify if the account associated to an email is in a specific team indicated with its id - * @param int $idTeam - * @param string $email - * @return bool - */ - public function isCoach(int $idTeam, string $email): bool { - return $this->members->isCoach($email, $idTeam); - } - - /** - * Edit a team with its id, and replace the current attributes with the new ones - * @param int $idTeam - * @param string $newName - * @param string $newPicture - * @param string $newMainColor - * @param string $newSecondColor - * @return void - */ - public function editTeam(int $idTeam, string $newName, string $newPicture, string $newMainColor, string $newSecondColor) { - $this->teams->editTeam($idTeam, $newName, $newPicture, $newMainColor, $newSecondColor); - } - - /** - * Get all user's teams - * - * @param integer $user - * @return array> - */ - public function getAll(int $user): array { - return $this->teams->getAll($user); - } -} diff --git a/src/Core/Validation/ComposedValidator.php b/src/Core/Validation/ComposedValidator.php deleted file mode 100644 index 58f4910..0000000 --- a/src/Core/Validation/ComposedValidator.php +++ /dev/null @@ -1,26 +0,0 @@ -first = $first; - $this->then = $then; - } - - public function validate(string $name, $val): array { - $firstFailures = $this->first->validate($name, $val); - $thenFailures = []; - if (empty($firstFailures)) { - $thenFailures = $this->then->validate($name, $val); - } - return array_merge($firstFailures, $thenFailures); - } -} diff --git a/src/Core/Validation/FieldValidationFail.php b/src/Core/Validation/FieldValidationFail.php deleted file mode 100644 index e3a127d..0000000 --- a/src/Core/Validation/FieldValidationFail.php +++ /dev/null @@ -1,42 +0,0 @@ -fieldName = $fieldName; - } - - public function getFieldName(): string { - return $this->fieldName; - } - - public static function invalidChars(string $fieldName): FieldValidationFail { - return new FieldValidationFail($fieldName, "field contains illegal chars"); - } - - public static function empty(string $fieldName): FieldValidationFail { - return new FieldValidationFail($fieldName, "field is empty"); - } - - public static function missing(string $fieldName): FieldValidationFail { - return new FieldValidationFail($fieldName, "field is missing"); - } - - /** - * @return array - */ - public function jsonSerialize(): array { - return ["field" => $this->fieldName, "message" => $this->getMessage()]; - } -} diff --git a/src/Core/Validation/FunctionValidator.php b/src/Core/Validation/FunctionValidator.php deleted file mode 100644 index 1bd18d7..0000000 --- a/src/Core/Validation/FunctionValidator.php +++ /dev/null @@ -1,21 +0,0 @@ -validate_fn = $validate_fn; - } - - public function validate(string $name, $val): array { - return call_user_func_array($this->validate_fn, [$name, $val]); - } -} diff --git a/src/Core/Validation/SimpleFunctionValidator.php b/src/Core/Validation/SimpleFunctionValidator.php deleted file mode 100644 index f19462b..0000000 --- a/src/Core/Validation/SimpleFunctionValidator.php +++ /dev/null @@ -1,33 +0,0 @@ - bool`, to validate the given string - * @param callable(string): ValidationFail[] $errorsFactory a factory function with signature `(string) => array` to emit failures when the predicate fails - */ - public function __construct(callable $predicate, callable $errorsFactory) { - $this->predicate = $predicate; - $this->errorFactory = $errorsFactory; - } - - public function validate(string $name, $val): array { - if (!call_user_func_array($this->predicate, [$val])) { - return call_user_func_array($this->errorFactory, [$name]); - } - return []; - } -} diff --git a/src/Core/Validation/Validation.php b/src/Core/Validation/Validation.php deleted file mode 100644 index 5b13354..0000000 --- a/src/Core/Validation/Validation.php +++ /dev/null @@ -1,29 +0,0 @@ -validate($valName, $val); - if ($error != null) { - $failures = array_merge($failures, $error); - $had_errors = true; - } - } - return $had_errors; - } - -} diff --git a/src/Core/Validation/ValidationFail.php b/src/Core/Validation/ValidationFail.php deleted file mode 100644 index 9a74a03..0000000 --- a/src/Core/Validation/ValidationFail.php +++ /dev/null @@ -1,56 +0,0 @@ -message = $message; - $this->kind = $kind; - } - - public function getMessage(): string { - return $this->message; - } - - public function getKind(): string { - return $this->kind; - } - - /** - * @return array - */ - public function jsonSerialize(): array { - return ["error" => $this->kind, "message" => $this->message]; - } - - /** - * @param string $message - * @return ValidationFail validation fail for unknown resource access - */ - public static function notFound(string $message): ValidationFail { - return new ValidationFail("Not found", $message); - } - - /** - * @param string $message - * @return ValidationFail validation fail for unauthorized accesses - */ - public static function unauthorized(string $message = "Unauthorized"): ValidationFail { - return new ValidationFail("Unauthorized", $message); - } - - public static function error(string $message): ValidationFail { - return new ValidationFail("Error", $message); - } - -} diff --git a/src/Core/Validation/Validator.php b/src/Core/Validation/Validator.php deleted file mode 100644 index d1761da..0000000 --- a/src/Core/Validation/Validator.php +++ /dev/null @@ -1,23 +0,0 @@ - { + const session = getSession() + const token = session?.auth?.token + + const headers: HeadersInit = { + Accept: "application/json", + "Content-Type": "application/json", + } + + if (token) { + headers.Authorization = token + } + + const response = await fetch(`${API}/${url}`, { + method, + headers, + body: JSON.stringify(payload), + }) + + return await handleResponse(session, response, redirectIfUnauthorized) +} + +export async function fetchAPIGet( + url: string, + redirectIfNotAuth: boolean = true, +): Promise { + const session = getSession() + const token = session?.auth?.token + + const headers: HeadersInit = { + Accept: "application/json", + "Content-Type": "application/json", + } + + if (token) { + headers.Authorization = token + } + + const response = await fetch(`${API}/${url}`, { + method: "GET", + headers, + }) + + return await handleResponse(session, response, redirectIfNotAuth) +} + +async function handleResponse( + session: Session, + response: Response, + redirectIfNotAuth: boolean, +): Promise { + // if we provided a token but still unauthorized, the token has expired + console.log(response.status) + if (response.status == 401) { + if (!redirectIfNotAuth) return response + saveSession({ ...session, urlTarget: location.pathname }) + return redirect("/login") + } + + const nextToken = response.headers.get("Next-Authorization")! + const expirationDate = Date.parse( + response.headers.get("Next-Authorization-Expiration-Date")!, + ) + saveSession({ ...session, auth: { token: nextToken, expirationDate } }) + + return response +} diff --git a/src/api/failure.ts b/src/api/failure.ts new file mode 100644 index 0000000..3ba5e88 --- /dev/null +++ b/src/api/failure.ts @@ -0,0 +1,4 @@ +export interface Failure { + type: string + messages: string[] +} diff --git a/src/api/session.ts b/src/api/session.ts new file mode 100644 index 0000000..a061228 --- /dev/null +++ b/src/api/session.ts @@ -0,0 +1,23 @@ +export interface Session { + auth?: Authentication + urlTarget?: string + username?: string +} + +export interface Authentication { + token: string + expirationDate: number +} + +const SESSION_KEY = "session" + +// export const SessionContext = createContext(getSession()) + +export function saveSession(session: Session) { + localStorage.setItem(SESSION_KEY, JSON.stringify(session)) +} + +export function getSession(): Session { + const json = localStorage.getItem(SESSION_KEY) + return json ? JSON.parse(json) : {} +} diff --git a/front/assets/account.svg b/src/assets/account.svg similarity index 100% rename from front/assets/account.svg rename to src/assets/account.svg diff --git a/front/assets/court/court.svg b/src/assets/court/court.svg similarity index 100% rename from front/assets/court/court.svg rename to src/assets/court/court.svg diff --git a/front/assets/court/full_court.svg b/src/assets/court/full_court.svg similarity index 100% rename from front/assets/court/full_court.svg rename to src/assets/court/full_court.svg diff --git a/front/assets/court/half_court.svg b/src/assets/court/half_court.svg similarity index 100% rename from front/assets/court/half_court.svg rename to src/assets/court/half_court.svg diff --git a/front/assets/favicon.ico b/src/assets/favicon.ico similarity index 100% rename from front/assets/favicon.ico rename to src/assets/favicon.ico diff --git a/front/assets/icon/account.png b/src/assets/icon/account.png similarity index 100% rename from front/assets/icon/account.png rename to src/assets/icon/account.png diff --git a/front/assets/icon/account.svg b/src/assets/icon/account.svg similarity index 100% rename from front/assets/icon/account.svg rename to src/assets/icon/account.svg diff --git a/src/assets/icon/add.svg b/src/assets/icon/add.svg new file mode 100644 index 0000000..1cc5bf2 --- /dev/null +++ b/src/assets/icon/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/assets/icon/arrow.svg b/src/assets/icon/arrow.svg similarity index 100% rename from front/assets/icon/arrow.svg rename to src/assets/icon/arrow.svg diff --git a/front/assets/icon/ball.svg b/src/assets/icon/ball.svg similarity index 100% rename from front/assets/icon/ball.svg rename to src/assets/icon/ball.svg diff --git a/front/assets/icon/remove.svg b/src/assets/icon/remove.svg similarity index 100% rename from front/assets/icon/remove.svg rename to src/assets/icon/remove.svg diff --git a/front/assets/logo.svg b/src/assets/logo.svg similarity index 100% rename from front/assets/logo.svg rename to src/assets/logo.svg diff --git a/front/assets/logo192.png b/src/assets/logo192.png similarity index 100% rename from front/assets/logo192.png rename to src/assets/logo192.png diff --git a/front/assets/logo512.png b/src/assets/logo512.png similarity index 100% rename from front/assets/logo512.png rename to src/assets/logo512.png diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/components/Rack.tsx b/src/components/Rack.tsx similarity index 100% rename from front/components/Rack.tsx rename to src/components/Rack.tsx diff --git a/front/components/TitleInput.tsx b/src/components/TitleInput.tsx similarity index 80% rename from front/components/TitleInput.tsx rename to src/components/TitleInput.tsx index 477e3d0..90447e2 100644 --- a/front/components/TitleInput.tsx +++ b/src/components/TitleInput.tsx @@ -1,16 +1,16 @@ -import React, { CSSProperties, useRef, useState } from "react" +import { CSSProperties, useRef, useState } from "react" import "../style/title_input.css" export interface TitleInputOptions { style: CSSProperties default_value: string - on_validated: (a: string) => void + onValidated: (a: string) => void } export default function TitleInput({ style, default_value, - on_validated, + onValidated, }: TitleInputOptions) { const [value, setValue] = useState(default_value) const ref = useRef(null) @@ -23,7 +23,7 @@ export default function TitleInput({ type="text" value={value} onChange={(event) => setValue(event.target.value)} - onBlur={(_) => on_validated(value)} + onBlur={() => onValidated(value)} onKeyUp={(event) => { if (event.key == "Enter") ref.current?.blur() }} diff --git a/front/components/actions/ArrowAction.tsx b/src/components/actions/ArrowAction.tsx similarity index 85% rename from front/components/actions/ArrowAction.tsx rename to src/components/actions/ArrowAction.tsx index 00a661c..8fbae5f 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/src/components/actions/ArrowAction.tsx @@ -44,18 +44,16 @@ export default function ArrowAction({ ) } -export function ScreenHead() { +export function ScreenHead({ color }: { color: string }) { return ( -
+
) } -export function MoveToHead() { +export function MoveToHead({ color }: { color: string }) { return ( - + ) } diff --git a/front/components/actions/BallAction.tsx b/src/components/actions/BallAction.tsx similarity index 63% rename from front/components/actions/BallAction.tsx rename to src/components/actions/BallAction.tsx index a26785c..87779df 100644 --- a/front/components/actions/BallAction.tsx +++ b/src/components/actions/BallAction.tsx @@ -1,15 +1,19 @@ import { BallPiece } from "../editor/BallPiece" import Draggable from "react-draggable" import { useRef } from "react" +import { NULL_POS } from "../../geo/Pos" export interface BallActionProps { - onDrop: (el: HTMLElement) => void + onDrop: (el: DOMRect) => void } export default function BallAction({ onDrop }: BallActionProps) { const ref = useRef(null) return ( - onDrop(ref.current!)} nodeRef={ref}> + onDrop(ref.current!.getBoundingClientRect())} + position={NULL_POS}>
diff --git a/front/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx similarity index 89% rename from front/components/arrows/BendableArrow.tsx rename to src/components/arrows/BendableArrow.tsx index b8f0f19..7a4760b 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/src/components/arrows/BendableArrow.tsx @@ -1,5 +1,6 @@ import { CSSProperties, + MouseEvent as ReactMouseEvent, ReactElement, RefObject, useCallback, @@ -7,29 +8,29 @@ import { useLayoutEffect, useRef, useState, - MouseEvent as ReactMouseEvent, } from "react" import { add, angle, - middle, distance, + middle, middlePos, minus, mul, + norm, + NULL_POS, Pos, posWithinBase, ratioWithinBase, relativeTo, - norm, -} from "./Pos" +} from "../../geo/Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" export interface BendableArrowProps { area: RefObject - startPos: Pos + startPos: Pos | string segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void forceStraight: boolean @@ -46,16 +47,18 @@ export interface BendableArrowProps { export interface ArrowStyle { width?: number dashArray?: string + color: string head?: () => ReactElement tail?: () => ReactElement } const ArrowStyleDefaults: ArrowStyle = { width: 3, + color: "black", } export interface Segment { - next: Pos + next: Pos | string controlPoint?: Pos } @@ -134,7 +137,7 @@ export default function BendableArrow({ } }) }, - [segments, startPos], + [startPos], ) // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint), @@ -147,7 +150,7 @@ export default function BendableArrow({ // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) - }, [startPos, segments, computeInternalSegments]) + }, [computeInternalSegments, segments]) const [isSelected, setIsSelected] = useState(false) @@ -162,8 +165,8 @@ export default function BendableArrow({ return segments.flatMap(({ next, controlPoint }, i) => { const prev = i == 0 ? startPos : segments[i - 1].next - const prevRelative = posWithinBase(prev, parentBase) - const nextRelative = posWithinBase(next, parentBase) + const prevRelative = getPosWithinBase(prev, parentBase) + const nextRelative = getPosWithinBase(next, parentBase) const cpPos = controlPoint || @@ -204,7 +207,7 @@ export default function BendableArrow({ { const currentSegment = segments[i] @@ -252,19 +255,19 @@ export default function BendableArrow({ const lastSegment = internalSegments[internalSegments.length - 1] - const startRelative = posWithinBase(startPos, parentBase) - const endRelative = posWithinBase(lastSegment.end, parentBase) + const startRelative = getPosWithinBase(startPos, parentBase) + const endRelative = getPosWithinBase(lastSegment.end, parentBase) const startNext = segment.controlPoint && !forceStraight ? posWithinBase(segment.controlPoint, parentBase) - : posWithinBase(segment.end, parentBase) + : getPosWithinBase(segment.end, parentBase) const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint ? posWithinBase(lastSegment.controlPoint, parentBase) - : posWithinBase(lastSegment.start, parentBase) + : getPosWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -309,15 +312,15 @@ export default function BendableArrow({ }, ] : internalSegments - ).map(({ start, controlPoint, end }, idx) => { + ).map(({ start, controlPoint, end }) => { const svgPosRelativeToBase = { x: left, y: top } const nextRelative = relativeTo( - posWithinBase(end, parentBase), + getPosWithinBase(end, parentBase), svgPosRelativeToBase, ) const startRelative = relativeTo( - posWithinBase(start, parentBase), + getPosWithinBase(start, parentBase), svgPosRelativeToBase, ) const controlPointRelative = @@ -355,14 +358,14 @@ export default function BendableArrow({ ? add(start, previousSegmentCpAndCurrentPosVector) : cp - if (wavy) { - return wavyBezier(start, smoothCp, cp, end, 10, 10) - } - if (forceStraight) { return `L${end.x} ${end.y}` } + if (wavy) { + return wavyBezier(start, smoothCp, cp, end, 10, 10) + } + return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}` }) .join(" ") @@ -371,17 +374,34 @@ export default function BendableArrow({ pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) }, [ - startPos, + area, internalSegments, + startPos, forceStraight, startRadius, endRadius, - style, + wavy, ]) // Will update the arrow when the props change 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 // Also force an update when the window is resized useEffect(() => { @@ -418,10 +438,16 @@ export default function BendableArrow({ for (let i = 0; i < segments.length; i++) { const segment = segments[i] const beforeSegment = i != 0 ? segments[i - 1] : undefined - const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos + const beforeSegmentPos = getRatioWithinBase( + i > 1 ? segments[i - 2].next : startPos, + parentBase, + ) - const currentPos = beforeSegment ? beforeSegment.next : startPos - const nextPos = segment.next + const currentPos = getRatioWithinBase( + beforeSegment ? beforeSegment.next : startPos, + parentBase, + ) + const nextPos = getRatioWithinBase(segment.next, parentBase) const segmentCp = segment.controlPoint ? segment.controlPoint : middle(currentPos, nextPos) @@ -493,7 +519,7 @@ export default function BendableArrow({ + return } diff --git a/src/components/editor/BasketCourt.tsx b/src/components/editor/BasketCourt.tsx new file mode 100644 index 0000000..2213525 --- /dev/null +++ b/src/components/editor/BasketCourt.tsx @@ -0,0 +1,56 @@ +import { ReactElement, ReactNode, RefObject } from "react" +import { Action } from "../../model/tactic/Action" + +import { CourtAction } from "./CourtAction.tsx" +import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" + +export interface BasketCourtProps { + components: TacticComponent[] + previewAction: ActionPreview | null + + renderComponent: (comp: TacticComponent) => ReactNode + renderActions: (comp: TacticComponent) => ReactNode[] + + courtImage: ReactElement + courtRef: RefObject +} + +export interface ActionPreview extends Action { + origin: ComponentId + isInvalid: boolean +} + +export function BasketCourt({ + components, + previewAction, + + renderComponent, + renderActions, + + courtImage, + courtRef, +}: BasketCourtProps) { + return ( +
+ {courtImage} + + {components.map(renderComponent)} + {components.flatMap(renderActions)} + + {previewAction && ( + {}} + onActionChanges={() => {}} + /> + )} +
+ ) +} diff --git a/front/views/editor/CourtAction.tsx b/src/components/editor/CourtAction.tsx similarity index 77% rename from front/views/editor/CourtAction.tsx rename to src/components/editor/CourtAction.tsx index de33224..c26c0d9 100644 --- a/front/views/editor/CourtAction.tsx +++ b/src/components/editor/CourtAction.tsx @@ -2,29 +2,36 @@ import { Action, ActionKind } from "../../model/tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" import { RefObject } from "react" import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" +import { ComponentId } from "../../model/tactic/Tactic" export interface CourtActionProps { + origin: ComponentId action: Action onActionChanges: (a: Action) => void onActionDeleted: () => void courtRef: RefObject + isInvalid: boolean } export function CourtAction({ + origin, action, onActionChanges, onActionDeleted, courtRef, + isInvalid, }: CourtActionProps) { + const color = isInvalid ? "red" : "black" + let head switch (action.type) { case ActionKind.DRIBBLE: case ActionKind.MOVE: case ActionKind.SHOOT: - head = () => + head = () => break case ActionKind.SCREEN: - head = () => + head = () => break } @@ -39,19 +46,20 @@ export function CourtAction({ { onActionChanges({ ...action, segments: edges }) }} wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants - endRadius={action.toPlayerId ? 26 : 17} - startRadius={0} + endRadius={action.target ? 26 : 17} + startRadius={10} onDeleteRequested={onActionDeleted} style={{ head, dashArray, + color, }} /> ) diff --git a/front/components/editor/CourtBall.tsx b/src/components/editor/CourtBall.tsx similarity index 66% rename from front/components/editor/CourtBall.tsx rename to src/components/editor/CourtBall.tsx index b1fa1d0..e1ac542 100644 --- a/front/components/editor/CourtBall.tsx +++ b/src/components/editor/CourtBall.tsx @@ -1,15 +1,16 @@ -import React, { useRef } from "react" +import { useRef } from "react" import Draggable from "react-draggable" import { BallPiece } from "./BallPiece" -import { Ball } from "../../model/tactic/Ball" +import { NULL_POS } from "../../geo/Pos" +import { Ball } from "../../model/tactic/CourtObjects" export interface CourtBallProps { - onMoved: (rect: DOMRect) => void + onPosValidated: (rect: DOMRect) => void onRemove: () => void ball: Ball } -export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { +export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { const pieceRef = useRef(null) const x = ball.rightRatio @@ -17,7 +18,10 @@ export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { return ( onMoved(pieceRef.current!.getBoundingClientRect())} + onStop={() => + onPosValidated(pieceRef.current!.getBoundingClientRect()) + } + position={NULL_POS} nodeRef={pieceRef}>
void - onChange: (p: Player) => void +export interface CourtPlayerProps { + playerInfo: PlayerInfo + className?: string + + onPositionValidated: (newPos: Pos) => void onRemove: () => void courtRef: RefObject availableActions: (ro: HTMLElement) => ReactNode[] @@ -18,44 +19,37 @@ export interface PlayerProps { * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ export default function CourtPlayer({ - player, - onDrag, - onChange, + playerInfo, + className, + + onPositionValidated, onRemove, courtRef, availableActions, -}: PlayerProps) { - const hasBall = player.hasBall - const x = player.rightRatio - const y = player.bottomRatio +}: CourtPlayerProps) { + const usesBall = playerInfo.ballState != BallState.NONE + const x = playerInfo.rightRatio + const y = playerInfo.bottomRatio const pieceRef = useRef(null) return ( { + onStop={useCallback(() => { const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect() - const { x, y } = ratioWithinBase(pieceBounds, parentBounds) + const pos = ratioWithinBase(pieceBounds, parentBounds) - onChange({ - id: player.id, - rightRatio: x, - bottomRatio: y, - team: player.team, - role: player.role, - hasBall: player.hasBall, - }) - }}> + if (pos.x !== x || pos.y != y) onPositionValidated(pos) + }, [courtRef, onPositionValidated, x, y])}>
{ - if (e.key == "Delete") onRemove() - }}> + onKeyUp={useCallback( + (e: React.KeyboardEvent) => { + if (e.key == "Delete") onRemove() + }, + [onRemove], + )}>
{availableActions(pieceRef.current!)}
diff --git a/front/components/editor/PlayerPiece.tsx b/src/components/editor/PlayerPiece.tsx similarity index 100% rename from front/components/editor/PlayerPiece.tsx rename to src/components/editor/PlayerPiece.tsx diff --git a/front/components/editor/SavingState.tsx b/src/components/editor/SavingState.tsx similarity index 100% rename from front/components/editor/SavingState.tsx rename to src/components/editor/SavingState.tsx diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts new file mode 100644 index 0000000..ad0c8bd --- /dev/null +++ b/src/editor/ActionsDomains.ts @@ -0,0 +1,510 @@ +import { + BallState, + Player, + PlayerPhantom, + PlayerLike, +} from "../model/tactic/Player" +import { ratioWithinBase } from "../geo/Pos" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { Action, ActionKind, moves } from "../model/tactic/Action" +import { removeBall, updateComponent } from "./TacticContentDomains" +import { + areInSamePath, + changePlayerBallState, + getOrigin, + isNextInPath, + removePlayer, +} from "./PlayerDomains" +import { BALL_TYPE } from "../model/tactic/CourtObjects" + +export function getActionKind( + target: TacticComponent | null, + ballState: BallState, +): { kind: ActionKind; nextState: BallState } { + switch (ballState) { + case BallState.HOLDS_ORIGIN: + return target + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN } + : { kind: ActionKind.DRIBBLE, nextState: ballState } + case BallState.HOLDS_BY_PASS: + return target + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED } + : { kind: ActionKind.DRIBBLE, nextState: ballState } + case BallState.PASSED_ORIGIN: + case BallState.PASSED: + case BallState.NONE: + return { + kind: + target && target.type != BALL_TYPE + ? ActionKind.SCREEN + : ActionKind.MOVE, + nextState: ballState, + } + } +} + +export function getActionKindBetween( + origin: PlayerLike, + target: TacticComponent | null, + state: BallState, +): { kind: ActionKind; nextState: BallState } { + //remove the target if the target is a phantom that is within the origin's path + if ( + target != null && + target.type == "phantom" && + areInSamePath(origin, target) + ) { + target = null + } + + return getActionKind(target, state) +} + +export function isActionValid( + origin: TacticComponent, + target: TacticComponent | null, + components: TacticComponent[], +): boolean { + /// action is valid if the origin is neither a phantom nor a player + if (origin.type != "phantom" && origin.type != "player") { + return true + } + + // action is invalid if the origin already moves (unless the origin holds a ball which will lead to a ball pass) + if ( + origin.ballState != BallState.HOLDS_BY_PASS && + origin.ballState != BallState.HOLDS_ORIGIN && + origin.actions.find((a) => moves(a.type)) + ) { + return false + } + //Action is valid if the target is null + if (target == null) { + return true + } + + // action is invalid if it targets its own origin + if (origin.id === target.id) { + return false + } + + // action is invalid if the target already moves and is not indirectly bound with origin + if ( + target.actions.find((a) => moves(a.type)) && + (hasBoundWith(target, origin, components) || + hasBoundWith(origin, target, components)) + ) { + return false + } + + // Action is invalid if there is already an action between origin and target. + if ( + origin.actions.find((a) => a.target === target?.id) || + target?.actions.find((a) => a.target === origin.id) + ) { + return false + } + + // Action is invalid if there is already an anterior action within the target's path + if (target.type == "phantom" || target.type == "player") { + // cant have an action with current path + if (areInSamePath(origin, target)) return false + + if (alreadyHasAnAnteriorActionWith(origin, target, components)) { + return false + } + } + + return true +} + +function hasBoundWith( + origin: TacticComponent, + target: TacticComponent, + components: TacticComponent[], +): boolean { + const toVisit = [origin.id] + const visited: string[] = [] + + let itemId: string | undefined + while ((itemId = toVisit.pop())) { + if (visited.indexOf(itemId) !== -1) continue + + visited.push(itemId) + + const item = components.find((c) => c.id === itemId)! + + const itemBounds = item.actions.flatMap((a) => + typeof a.target == "string" ? [a.target] : [], + ) + if (itemBounds.indexOf(target.id) !== -1) { + return true + } + + toVisit.push(...itemBounds) + } + + return false +} + +function alreadyHasAnAnteriorActionWith( + origin: PlayerLike, + target: PlayerLike, + components: TacticComponent[], +): boolean { + const targetOrigin = + target.type === "phantom" ? getOrigin(target, components) : target + const targetOriginPath = [ + targetOrigin.id, + ...(targetOrigin.path?.items ?? []), + ] + + const originOrigin = + origin.type === "phantom" ? getOrigin(origin, components) : origin + const originOriginPath = [ + originOrigin.id, + ...(originOrigin.path?.items ?? []), + ] + + const targetIdx = targetOriginPath.indexOf(target.id) + for (let i = targetIdx; i < targetOriginPath.length; i++) { + const phantom = components.find( + (c) => c.id === targetOriginPath[i], + )! as PlayerLike + if ( + phantom.actions.find( + (a) => + typeof a.target === "string" && + moves(a.type) && + originOriginPath.indexOf(a.target) !== -1, + ) + ) { + return true + } + } + + const originIdx = originOriginPath.indexOf(origin.id) + for (let i = 0; i <= originIdx; i++) { + const phantom = components.find( + (c) => c.id === originOriginPath[i], + )! as PlayerLike + if ( + phantom.actions.find( + (a) => + typeof a.target === "string" && + moves(a.type) && + targetOriginPath.indexOf(a.target) > targetIdx, + ) + ) { + return true + } + } + + return false +} + +export function createAction( + origin: PlayerLike, + courtBounds: DOMRect, + arrowHead: DOMRect, + content: TacticContent, +): { createdAction: Action; newContent: TacticContent } { + /** + * Creates a new phantom component. + * Be aware that this function will reassign the `content` parameter. + */ + function createPhantom(forceHasBall: boolean): ComponentId { + const { x, y } = ratioWithinBase(arrowHead, courtBounds) + + let itemIndex: number + let originPlayer: Player + + if (origin.type == "phantom") { + // if we create a phantom from another phantom, + // simply add it to the phantom's path + const originPlr = getOrigin(origin, content.components)! + itemIndex = originPlr.path!.items.length + originPlayer = originPlr + } else { + // if we create a phantom directly from a player + // create a new path and add it into + itemIndex = 0 + originPlayer = origin + } + + const path = originPlayer.path + + const phantomId = "phantom-" + itemIndex + "-" + originPlayer.id + + content = updateComponent( + { + ...originPlayer, + path: { + items: path ? [...path.items, phantomId] : [phantomId], + }, + }, + content, + ) + + let phantomState: BallState + if (forceHasBall) phantomState = BallState.HOLDS_ORIGIN + else + switch (origin.ballState) { + case BallState.HOLDS_ORIGIN: + phantomState = BallState.HOLDS_BY_PASS + break + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + phantomState = BallState.NONE + break + default: + phantomState = origin.ballState + } + + const phantom: PlayerPhantom = { + type: "phantom", + id: phantomId, + rightRatio: x, + bottomRatio: y, + originPlayerId: originPlayer.id, + ballState: phantomState, + actions: [], + } + content = { + ...content, + components: [...content.components, phantom], + } + return phantom.id + } + + for (const component of content.components) { + if (component.id == origin.id) { + continue + } + + const componentBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(componentBounds, arrowHead)) { + let toId = component.id + + if (component.type == "ball") { + toId = createPhantom(true) + content = removeBall(content) + } + + const action: Action = { + target: toId, + type: getActionKind(component, origin.ballState).kind, + segments: [{ next: toId }], + } + + return { + newContent: updateComponent( + { + ...content.components.find((c) => c.id == origin.id)!, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, + } + } + } + + const phantomId = createPhantom(false) + + const action: Action = { + target: phantomId, + type: getActionKind(null, origin.ballState).kind, + segments: [{ next: phantomId }], + } + return { + newContent: updateComponent( + { + ...content.components.find((c) => c.id == origin.id)!, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, + } +} + +export function removeAllActionsTargeting( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + const components = [] + for (let i = 0; i < content.components.length; i++) { + const component = content.components[i] + components.push({ + ...component, + actions: component.actions.filter((a) => a.target != componentId), + }) + } + + return { + ...content, + components, + } +} + +export function removeAction( + origin: TacticComponent, + action: Action, + actionIdx: number, + content: TacticContent, +): TacticContent { + origin = { + ...origin, + actions: origin.actions.toSpliced(actionIdx, 1), + } + content = updateComponent(origin, content) + + if (action.target == null) return content + + const target = content.components.find((c) => action.target == c.id)! + + // if the removed action is a shoot, set the origin as holding the ball + if ( + action.type == ActionKind.SHOOT && + (origin.type === "player" || origin.type === "phantom") + ) { + if (target.type === "player" || target.type === "phantom") + content = changePlayerBallState(target, BallState.NONE, content) + + if (origin.ballState === BallState.PASSED) { + content = changePlayerBallState( + origin, + BallState.HOLDS_BY_PASS, + content, + ) + } else if (origin.ballState === BallState.PASSED_ORIGIN) { + content = changePlayerBallState( + origin, + BallState.HOLDS_ORIGIN, + content, + ) + } + } + + if (target.type === "phantom") { + let path = null + if (origin.type === "player") { + path = origin.path + } else if (origin.type === "phantom") { + path = getOrigin(origin, content.components).path + } + + if (path != null && path.items.find((c) => c === target.id)) { + content = removePlayer(target, content) + } + } + + return content +} + +/** + * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with + * the given newState. + * @param origin + * @param newState + * @param content + */ +export function spreadNewStateFromOriginStateChange( + origin: PlayerLike, + newState: BallState, + content: TacticContent, +): TacticContent { + if (origin.ballState === newState) { + return content + } + + origin = { + ...origin, + ballState: newState, + } + + content = updateComponent(origin, content) + + for (let i = 0; i < origin.actions.length; i++) { + const action = origin.actions[i] + if (typeof action.target !== "string") { + continue + } + + const actionTarget = content.components.find( + (c) => action.target === c.id, + )! as PlayerLike + + let targetState: BallState = actionTarget.ballState + let deleteAction = false + + if (isNextInPath(origin, actionTarget, content.components)) { + switch (newState) { + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + targetState = BallState.NONE + break + case BallState.HOLDS_ORIGIN: + targetState = BallState.HOLDS_BY_PASS + break + default: + targetState = newState + } + } else if ( + newState === BallState.NONE && + action.type === ActionKind.SHOOT + ) { + /// if the new state removes the ball from the player, remove all actions that were meant to shoot the ball + deleteAction = true + targetState = BallState.NONE // Then remove the ball for the target as well + } else if ( + (newState === BallState.HOLDS_BY_PASS || + newState === BallState.HOLDS_ORIGIN) && + action.type === ActionKind.SCREEN + ) { + targetState = BallState.HOLDS_BY_PASS + } + + if (deleteAction) { + content = removeAction(origin, action, i, content) + origin = content.components.find((c) => c.id === origin.id)! as + | Player + | PlayerPhantom + i-- // step back + } else { + // do not change the action type if it is a shoot action + const { kind, nextState } = getActionKindBetween( + origin, + actionTarget, + newState, + ) + + origin = { + ...origin, + ballState: nextState, + actions: origin.actions.toSpliced(i, 1, { + ...action, + type: kind, + }), + } + content = updateComponent(origin, content) + } + + content = spreadNewStateFromOriginStateChange( + actionTarget, + targetState, + content, + ) + } + + return content +} diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts new file mode 100644 index 0000000..b20ca9d --- /dev/null +++ b/src/editor/PlayerDomains.ts @@ -0,0 +1,149 @@ +import { + BallState, + Player, + PlayerLike, + PlayerPhantom, +} from "../model/tactic/Player" +import { TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { removeComponent, updateComponent } from "./TacticContentDomains" +import { + removeAllActionsTargeting, + spreadNewStateFromOriginStateChange, +} from "./ActionsDomains" +import { ActionKind } from "../model/tactic/Action" + +export function getOrigin( + pathItem: PlayerPhantom, + components: TacticComponent[], +): Player { + // Trust the components to contains only phantoms with valid player origin identifiers + return components.find((c) => c.id == pathItem.originPlayerId)! as Player +} + +export function areInSamePath(a: PlayerLike, b: PlayerLike) { + if (a.type === "phantom" && b.type === "phantom") { + return a.originPlayerId === b.originPlayerId + } + if (a.type === "phantom") { + return b.id === a.originPlayerId + } + if (b.type === "phantom") { + return a.id === b.originPlayerId + } + return false +} + +/** + * @param origin + * @param other + * @param components + * @returns true if the `other` player is the phantom next-to the origin's path. + */ +export function isNextInPath( + origin: PlayerLike, + other: PlayerLike, + components: TacticComponent[], +): boolean { + if (origin.type === "player") { + return origin.path?.items[0] === other.id + } + const originPath = getOrigin(origin, components).path! + return ( + originPath.items!.indexOf(origin.id) === + originPath.items!.indexOf(other.id) - 1 + ) +} + +export function removePlayerPath( + player: Player, + content: TacticContent, +): TacticContent { + if (player.path == null) { + return content + } + + for (const pathElement of player.path.items) { + content = removeComponent(pathElement, content) + content = removeAllActionsTargeting(pathElement, content) + } + return updateComponent( + { + ...player, + path: null, + }, + content, + ) +} + +export function removePlayer( + player: PlayerLike, + content: TacticContent, +): TacticContent { + content = removeAllActionsTargeting(player.id, content) + + if (player.type == "phantom") { + const origin = getOrigin(player, content.components) + return truncatePlayerPath(origin, player, content) + } + + content = removePlayerPath(player, content) + content = removeComponent(player.id, content) + + for (const action of player.actions) { + if (action.type !== ActionKind.SHOOT) { + continue + } + const actionTarget = content.components.find( + (c) => c.id === action.target, + )! as PlayerLike + return spreadNewStateFromOriginStateChange( + actionTarget, + BallState.NONE, + content, + ) + } + + return content +} + +export function truncatePlayerPath( + player: Player, + phantom: PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.path == null) return content + + const path = player.path! + + const truncateStartIdx = path.items.indexOf(phantom.id) + + for (let i = truncateStartIdx; i < path.items.length; i++) { + const pathPhantomId = path.items[i] + + //remove the phantom from the tactic + content = removeComponent(pathPhantomId, content) + content = removeAllActionsTargeting(pathPhantomId, content) + } + + return updateComponent( + { + ...player, + path: + truncateStartIdx == 0 + ? null + : { + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, + }, + content, + ) +} + +export function changePlayerBallState( + player: PlayerLike, + newState: BallState, + content: TacticContent, +): TacticContent { + return spreadNewStateFromOriginStateChange(player, newState, content) +} diff --git a/src/editor/RackedItems.ts b/src/editor/RackedItems.ts new file mode 100644 index 0000000..f2df151 --- /dev/null +++ b/src/editor/RackedItems.ts @@ -0,0 +1,11 @@ +/** + * information about a player that is into a rack + */ +import { PlayerTeam } from "../model/tactic/Player" + +export interface RackedPlayer { + team: PlayerTeam + key: string +} + +export type RackedCourtObject = { key: "ball" } diff --git a/src/editor/StepContentDomains.ts b/src/editor/StepContentDomains.ts new file mode 100644 index 0000000..5839bee --- /dev/null +++ b/src/editor/StepContentDomains.ts @@ -0,0 +1,243 @@ +import { Pos, ratioWithinBase } from "../geo/Pos" +import { + BallState, + Player, + PlayerInfo, + PlayerTeam, +} from "../model/tactic/Player" +import { + Ball, + BALL_ID, + BALL_TYPE, + CourtObject, +} from "../model/tactic/CourtObjects" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { RackedCourtObject, RackedPlayer } from "./RackedItems" +import { changePlayerBallState } from "./PlayerDomains" + +export function placePlayerAt( + refBounds: DOMRect, + courtBounds: DOMRect, + element: RackedPlayer, +): Player { + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + return { + type: "player", + id: "player-" + element.key + "-" + element.team, + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + ballState: BallState.NONE, + path: null, + actions: [], + } +} + +export function placeObjectAt( + refBounds: DOMRect, + courtBounds: DOMRect, + rackedObject: RackedCourtObject, + content: TacticContent, +): TacticContent { + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + let courtObject: CourtObject + + switch (rackedObject.key) { + case BALL_TYPE: + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content, true) + } + + courtObject = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + break + + default: + throw new Error("unknown court object " + rackedObject.key) + } + + return { + ...content, + components: [...content.components, courtObject], + } +} + +export function dropBallOnComponent( + targetedComponentIdx: number, + content: TacticContent, + setAsOrigin: boolean, +): TacticContent { + const component = content.components[targetedComponentIdx] + + if (component.type === "player" || component.type === "phantom") { + const newState = + setAsOrigin || + component.ballState === BallState.PASSED_ORIGIN || + component.ballState === BallState.HOLDS_ORIGIN + ? BallState.HOLDS_ORIGIN + : BallState.HOLDS_BY_PASS + + content = changePlayerBallState(component, newState, content) + } + + return removeBall(content) +} + +export function removeBall(content: TacticContent): TacticContent { + const ballObjIdx = content.components.findIndex((o) => o.type == "ball") + + if (ballObjIdx == -1) { + return content + } + + return { + ...content, + components: content.components.toSpliced(ballObjIdx, 1), + } +} + +export function placeBallAt( + refBounds: DOMRect, + courtBounds: DOMRect, + content: TacticContent, +): TacticContent { + if (!overlaps(courtBounds, refBounds)) { + return removeBall(content) + } + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content, true) + } + + const ballIdx = content.components.findIndex((o) => o.type == "ball") + + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + const ball: Ball = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + + let components = content.components + + if (ballIdx != -1) { + components = components.toSpliced(ballIdx, 1, ball) + } else { + components = components.concat(ball) + } + + return { + ...content, + components, + } +} + +export function moveComponent( + newPos: Pos, + component: TacticComponent, + info: PlayerInfo, + courtBounds: DOMRect, + content: TacticContent, + removed: (content: TacticContent) => TacticContent, +): TacticContent { + const playerBounds = document + .getElementById(info.id)! + .getBoundingClientRect() + + // if the piece is no longer on the court, remove it + if (!overlaps(playerBounds, courtBounds)) { + return removed(content) + } + return updateComponent( + { + ...component, + rightRatio: newPos.x, + bottomRatio: newPos.y, + }, + content, + ) +} + +export function removeComponent( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + return { + ...content, + components: content.components.filter((c) => c.id !== componentId), + } +} + +export function updateComponent( + component: TacticComponent, + content: TacticContent, +): TacticContent { + return { + ...content, + components: content.components.map((c) => + c.id === component.id ? component : c, + ), + } +} + +export function getComponentCollided( + bounds: DOMRect, + components: TacticComponent[], + ignore?: ComponentId, +): number | -1 { + for (let i = 0; i < components.length; i++) { + const component = components[i] + + if (component.id == ignore) continue + + const playerBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(playerBounds, bounds)) { + return i + } + } + return -1 +} + +export function getRackPlayers( + team: PlayerTeam, + components: TacticComponent[], +): RackedPlayer[] { + return ["1", "2", "3", "4", "5"] + .filter( + (role) => + components.findIndex( + (c) => + c.type == "player" && c.team == team && c.role == role, + ) == -1, + ) + .map((key) => ({ team, key })) +} diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts new file mode 100644 index 0000000..5839bee --- /dev/null +++ b/src/editor/TacticContentDomains.ts @@ -0,0 +1,243 @@ +import { Pos, ratioWithinBase } from "../geo/Pos" +import { + BallState, + Player, + PlayerInfo, + PlayerTeam, +} from "../model/tactic/Player" +import { + Ball, + BALL_ID, + BALL_TYPE, + CourtObject, +} from "../model/tactic/CourtObjects" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { RackedCourtObject, RackedPlayer } from "./RackedItems" +import { changePlayerBallState } from "./PlayerDomains" + +export function placePlayerAt( + refBounds: DOMRect, + courtBounds: DOMRect, + element: RackedPlayer, +): Player { + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + return { + type: "player", + id: "player-" + element.key + "-" + element.team, + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + ballState: BallState.NONE, + path: null, + actions: [], + } +} + +export function placeObjectAt( + refBounds: DOMRect, + courtBounds: DOMRect, + rackedObject: RackedCourtObject, + content: TacticContent, +): TacticContent { + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + let courtObject: CourtObject + + switch (rackedObject.key) { + case BALL_TYPE: + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content, true) + } + + courtObject = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + break + + default: + throw new Error("unknown court object " + rackedObject.key) + } + + return { + ...content, + components: [...content.components, courtObject], + } +} + +export function dropBallOnComponent( + targetedComponentIdx: number, + content: TacticContent, + setAsOrigin: boolean, +): TacticContent { + const component = content.components[targetedComponentIdx] + + if (component.type === "player" || component.type === "phantom") { + const newState = + setAsOrigin || + component.ballState === BallState.PASSED_ORIGIN || + component.ballState === BallState.HOLDS_ORIGIN + ? BallState.HOLDS_ORIGIN + : BallState.HOLDS_BY_PASS + + content = changePlayerBallState(component, newState, content) + } + + return removeBall(content) +} + +export function removeBall(content: TacticContent): TacticContent { + const ballObjIdx = content.components.findIndex((o) => o.type == "ball") + + if (ballObjIdx == -1) { + return content + } + + return { + ...content, + components: content.components.toSpliced(ballObjIdx, 1), + } +} + +export function placeBallAt( + refBounds: DOMRect, + courtBounds: DOMRect, + content: TacticContent, +): TacticContent { + if (!overlaps(courtBounds, refBounds)) { + return removeBall(content) + } + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content, true) + } + + const ballIdx = content.components.findIndex((o) => o.type == "ball") + + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + const ball: Ball = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + + let components = content.components + + if (ballIdx != -1) { + components = components.toSpliced(ballIdx, 1, ball) + } else { + components = components.concat(ball) + } + + return { + ...content, + components, + } +} + +export function moveComponent( + newPos: Pos, + component: TacticComponent, + info: PlayerInfo, + courtBounds: DOMRect, + content: TacticContent, + removed: (content: TacticContent) => TacticContent, +): TacticContent { + const playerBounds = document + .getElementById(info.id)! + .getBoundingClientRect() + + // if the piece is no longer on the court, remove it + if (!overlaps(playerBounds, courtBounds)) { + return removed(content) + } + return updateComponent( + { + ...component, + rightRatio: newPos.x, + bottomRatio: newPos.y, + }, + content, + ) +} + +export function removeComponent( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + return { + ...content, + components: content.components.filter((c) => c.id !== componentId), + } +} + +export function updateComponent( + component: TacticComponent, + content: TacticContent, +): TacticContent { + return { + ...content, + components: content.components.map((c) => + c.id === component.id ? component : c, + ), + } +} + +export function getComponentCollided( + bounds: DOMRect, + components: TacticComponent[], + ignore?: ComponentId, +): number | -1 { + for (let i = 0; i < components.length; i++) { + const component = components[i] + + if (component.id == ignore) continue + + const playerBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(playerBounds, bounds)) { + return i + } + } + return -1 +} + +export function getRackPlayers( + team: PlayerTeam, + components: TacticComponent[], +): RackedPlayer[] { + return ["1", "2", "3", "4", "5"] + .filter( + (role) => + components.findIndex( + (c) => + c.type == "player" && c.team == team && c.role == role, + ) == -1, + ) + .map((key) => ({ team, key })) +} diff --git a/front/components/arrows/Box.ts b/src/geo/Box.ts similarity index 81% rename from front/components/arrows/Box.ts rename to src/geo/Box.ts index 36a674f..81c18d2 100644 --- a/front/components/arrows/Box.ts +++ b/src/geo/Box.ts @@ -28,6 +28,14 @@ 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 { return ( pos.x >= box.x && diff --git a/front/components/arrows/Pos.ts b/src/geo/Pos.ts similarity index 100% rename from front/components/arrows/Pos.ts rename to src/geo/Pos.ts diff --git a/src/index-utils.php b/src/index-utils.php deleted file mode 100644 index eb600bc..0000000 --- a/src/index-utils.php +++ /dev/null @@ -1,21 +0,0 @@ - + + , +) diff --git a/front/model/Team.ts b/src/model/Team.ts similarity index 100% rename from front/model/Team.ts rename to src/model/Team.ts diff --git a/front/model/User.ts b/src/model/User.ts similarity index 100% rename from front/model/User.ts rename to src/model/User.ts diff --git a/front/model/tactic/Action.ts b/src/model/tactic/Action.ts similarity index 60% rename from front/model/tactic/Action.ts rename to src/model/tactic/Action.ts index 0b5aee5..c97cdd4 100644 --- a/front/model/tactic/Action.ts +++ b/src/model/tactic/Action.ts @@ -1,6 +1,6 @@ -import { Pos } from "../../components/arrows/Pos" +import { Pos } from "../../geo/Pos" import { Segment } from "../../components/arrows/BendableArrow" -import { PlayerId } from "./Player" +import { ComponentId } from "./Tactic" export enum ActionKind { SCREEN = "SCREEN", @@ -12,8 +12,10 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - fromPlayerId: PlayerId - toPlayerId?: PlayerId - moveFrom: Pos + target: ComponentId | Pos segments: Segment[] } + +export function moves(kind: ActionKind): boolean { + return kind != ActionKind.SHOOT +} diff --git a/src/model/tactic/CourtObjects.ts b/src/model/tactic/CourtObjects.ts new file mode 100644 index 0000000..96cde26 --- /dev/null +++ b/src/model/tactic/CourtObjects.ts @@ -0,0 +1,9 @@ +import { Component } from "./Tactic" + +export const BALL_ID = "ball" +export const BALL_TYPE = "ball" + +//place here all different kinds of objects +export type CourtObject = Ball + +export type Ball = Component diff --git a/src/model/tactic/Player.ts b/src/model/tactic/Player.ts new file mode 100644 index 0000000..a257103 --- /dev/null +++ b/src/model/tactic/Player.ts @@ -0,0 +1,75 @@ +import { Component, ComponentId } from "./Tactic" + +export type PlayerId = string + +export type PlayerLike = Player | PlayerPhantom + +export enum PlayerTeam { + Allies = "allies", + Opponents = "opponents", +} + +export interface Player extends PlayerInfo, Component<"player"> { + readonly id: PlayerId +} + +/** + * All information about a player + */ +export interface PlayerInfo { + readonly id: string + /** + * the player's team + * */ + readonly team: PlayerTeam + + /** + * player's role + * */ + readonly role: string + + /** + * True if the player has a basketball + */ + readonly ballState: BallState + + /** + * 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 +} + +export enum BallState { + NONE, + HOLDS_ORIGIN, + HOLDS_BY_PASS, + PASSED, + PASSED_ORIGIN, +} + +export interface Player extends Component<"player">, PlayerInfo { + /** + * True if the player has a basketball + */ + readonly ballState: BallState + + readonly path: MovementPath | null +} + +export interface MovementPath { + readonly items: ComponentId[] +} + +/** + * A player phantom is a kind of component that represents the future state of a player + * according to the court's step information + */ +export interface PlayerPhantom extends Component<"phantom"> { + readonly originPlayerId: ComponentId + readonly ballState: BallState +} diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts new file mode 100644 index 0000000..acce6f0 --- /dev/null +++ b/src/model/tactic/Tactic.ts @@ -0,0 +1,41 @@ +import { Player, PlayerPhantom } from "./Player" +import { Action } from "./Action" +import { CourtObject } from "./CourtObjects" + +export type CourtType = "HALF" | "PLAIN" + +export interface Tactic { + id: number + name: string + courtType: CourtType + content: TacticContent +} + +export interface TacticContent { + components: TacticComponent[] +} + +export type TacticComponent = Player | CourtObject | PlayerPhantom +export type ComponentId = string + +export interface Component { + /** + * The component's type + */ + readonly type: T + /** + * The component's identifier + */ + readonly id: ComponentId + /** + * Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + */ + readonly bottomRatio: number + + /** + * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number + + readonly actions: Action[] +} diff --git a/src/model/tactic/TacticInfo.ts b/src/model/tactic/TacticInfo.ts new file mode 100644 index 0000000..dfe1190 --- /dev/null +++ b/src/model/tactic/TacticInfo.ts @@ -0,0 +1,39 @@ +import { Player, PlayerPhantom } from "./Player" +import { Action } from "./Action" +import { CourtObject } from "./CourtObjects" + +export interface Tactic { + id: number + name: string + content: TacticContent +} + +export interface TacticContent { + components: TacticComponent[] + //actions: Action[] +} + +export type TacticComponent = Player | CourtObject | PlayerPhantom +export type ComponentId = string + +export interface Component { + /** + * The component's type + */ + readonly type: T + /** + * The component's identifier + */ + readonly id: ComponentId + /** + * Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + */ + readonly bottomRatio: number + + /** + * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number + + readonly actions: Action[] +} diff --git a/src/pages/404.tsx b/src/pages/404.tsx new file mode 100644 index 0000000..33c4b58 --- /dev/null +++ b/src/pages/404.tsx @@ -0,0 +1,14 @@ +import { useLocation } from "react-router-dom" +import { BASE } from "../Constants.ts" +export default function NotFoundPage() { + const target = useLocation() + + return ( +
+

{target.pathname} NOT FOUND !

+ +
+ ) +} diff --git a/src/pages/CreateTeamPage.tsx b/src/pages/CreateTeamPage.tsx new file mode 100644 index 0000000..e7deacc --- /dev/null +++ b/src/pages/CreateTeamPage.tsx @@ -0,0 +1,3 @@ +export default function CreateTeamPage() { + return

Create Team Page

+} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx new file mode 100644 index 0000000..6277ad2 --- /dev/null +++ b/src/pages/Editor.tsx @@ -0,0 +1,816 @@ +import { + CSSProperties, + Dispatch, + RefObject, + SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import "../style/editor.css" +import TitleInput from "../components/TitleInput" +import PlainCourt from "../assets/court/full_court.svg?react" +import HalfCourt from "../assets/court/half_court.svg?react" + +import { BallPiece } from "../components/editor/BallPiece" + +import { Rack } from "../components/Rack" +import { PlayerPiece } from "../components/editor/PlayerPiece" + +import { + CourtType, + Tactic, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { fetchAPI, fetchAPIGet } from "../Fetcher" + +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" + +import { BALL_TYPE } from "../model/tactic/CourtObjects" +import { CourtAction } from "../components/editor/CourtAction" +import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" +import { overlaps } from "../geo/Box" +import { + dropBallOnComponent, + getComponentCollided, + getRackPlayers, + moveComponent, + placeBallAt, + placeObjectAt, + placePlayerAt, + removeBall, + updateComponent, +} from "../editor/TacticContentDomains" +import { + BallState, + Player, + PlayerInfo, + PlayerLike, + PlayerTeam, +} from "../model/tactic/Player" +import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" +import CourtPlayer from "../components/editor/CourtPlayer" +import { + createAction, + getActionKind, + isActionValid, + removeAction, +} from "../editor/ActionsDomains" +import ArrowAction from "../components/actions/ArrowAction" +import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" +import { Action, ActionKind } from "../model/tactic/Action" +import BallAction from "../components/actions/BallAction" +import { + changePlayerBallState, + getOrigin, + removePlayer, +} from "../editor/PlayerDomains" +import { CourtBall } from "../components/editor/CourtBall" +import { useParams } from "react-router-dom" +import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx" + +const ERROR_STYLE: CSSProperties = { + borderColor: "red", +} + +const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" +const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" + +export interface EditorViewProps { + tactic: Tactic + onContentChange: (tactic: TacticContent) => Promise + onNameChange: (name: string) => Promise +} +interface TacticDto { + id: number + name: string + courtType: CourtType + content: string +} + +interface EditorPageProps { + guestMode: boolean +} + +export default function EditorPage({ guestMode }: EditorPageProps) { + const [tactic, setTactic] = useState(() => { + if (guestMode) { + return { + id: -1, + courtType: "PLAIN", + content: '{"components": []}', + name: DEFAULT_TACTIC_NAME, + } + } + return null; + }) + const { tacticId: idStr } = useParams() + const id = guestMode ? -1 : parseInt(idStr!) + + useEffect(() => { + if (guestMode) + return + + async function initialize() { + const infoResponse = fetchAPIGet(`tactics/${id}`) + const contentResponse = fetchAPIGet(`tactics/${id}/1`) + const { name, courtType } = await (await infoResponse).json() + const { content } = await (await contentResponse).json() + + setTactic({ id, name, courtType, content }) + } + + initialize() + }, [guestMode, id, idStr]) + + if (tactic) { + return ( + + ) + } + + return +} + +function EditorLoadingScreen() { + return
Loading Editor, please wait...
+} + +export interface EditorProps { + id: number + name: string + content: string + courtType: CourtType +} + +function Editor({ id, name, courtType, content }: EditorProps) { + const isInGuestMode = id == -1 + + const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) + const editorContent = + isInGuestMode && storageContent != null ? storageContent : content + + const storageName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) + const editorName = isInGuestMode && storageName != null ? storageName : name + + return ( + { + if (isInGuestMode) { + localStorage.setItem( + GUEST_MODE_CONTENT_STORAGE_KEY, + JSON.stringify(content), + ) + return SaveStates.Guest + } + return fetchAPI(`tactics/${id}/1`, { content }, "PUT").then( + (r) => (r.ok ? SaveStates.Ok : SaveStates.Err), + ) + }} + onNameChange={async (name: string) => { + if (isInGuestMode) { + localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) + return true //simulate that the name has been changed + } + return fetchAPI(`tactics/${id}/name`, { name }, "PUT").then( + (r) => r.ok, + ) + }} + /> + ) +} + +function EditorView({ + tactic: { id, name, content: initialContent, courtType }, + onContentChange, + onNameChange, +}: EditorViewProps) { + const isInGuestMode = id == -1 + + const [titleStyle, setTitleStyle] = useState({}) + const [content, setContent, saveState] = useContentState( + initialContent, + isInGuestMode ? SaveStates.Guest : SaveStates.Ok, + useMemo(() => debounceAsync(onContentChange), [onContentChange]), + ) + + const [allies, setAllies] = useState(() => + getRackPlayers(PlayerTeam.Allies, content.components), + ) + const [opponents, setOpponents] = useState(() => + getRackPlayers(PlayerTeam.Opponents, content.components), + ) + + const [objects, setObjects] = useState(() => + isBallOnCourt(content) ? [] : [{ key: "ball" }], + ) + + const [previewAction, setPreviewAction] = useState( + null, + ) + + const courtRef = useRef(null) + + const setComponents = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + components: + typeof action == "function" ? action(c.components) : action, + })) + } + + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + useEffect(() => { + setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) + }, [setObjects, content]) + + const insertRackedPlayer = (player: Player) => { + let setter + switch (player.team) { + case PlayerTeam.Opponents: + setter = setOpponents + break + case PlayerTeam.Allies: + setter = setAllies + } + if (player.ballState == BallState.HOLDS_BY_PASS) { + setObjects([{ key: "ball" }]) + } + setter((players) => [ + ...players, + { + team: player.team, + pos: player.role, + key: player.role, + }, + ]) + } + + const doRemovePlayer = useCallback( + (component: PlayerLike) => { + setContent((c) => removePlayer(component, c)) + if (component.type == "player") insertRackedPlayer(component) + }, + [setContent], + ) + + const doMoveBall = useCallback( + (newBounds: DOMRect, from?: PlayerLike) => { + setContent((content) => { + if (from) { + content = changePlayerBallState( + from, + BallState.NONE, + content, + ) + } + + content = placeBallAt(newBounds, courtBounds(), content) + + return content + }) + }, + [courtBounds, setContent], + ) + + const validatePlayerPosition = useCallback( + (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { + setContent((content) => + moveComponent( + newPos, + player, + info, + courtBounds(), + content, + + (content) => { + if (player.type == "player") insertRackedPlayer(player) + return removePlayer(player, content) + }, + ), + ) + }, + [courtBounds, setContent], + ) + + const renderAvailablePlayerActions = useCallback( + (info: PlayerInfo, player: PlayerLike) => { + let canPlaceArrows: boolean + + if (player.type == "player") { + canPlaceArrows = + player.path == null || + player.actions.findIndex( + (p) => p.type != ActionKind.SHOOT, + ) == -1 + } else { + const origin = getOrigin(player, content.components) + const path = origin.path! + // phantoms can only place other arrows if they are the head of the path + canPlaceArrows = + path.items.indexOf(player.id) == path.items.length - 1 + if (canPlaceArrows) { + // and if their only action is to shoot the ball + const phantomActions = player.actions + canPlaceArrows = + phantomActions.length == 0 || + phantomActions.findIndex( + (c) => c.type != ActionKind.SHOOT, + ) == -1 + } + } + + return [ + canPlaceArrows && ( + + ), + (info.ballState === BallState.HOLDS_ORIGIN || + info.ballState === BallState.PASSED_ORIGIN) && ( + { + doMoveBall(ballBounds, player) + }} + /> + ), + ] + }, + [content, doMoveBall, previewAction?.isInvalid, setContent], + ) + + const renderPlayer = useCallback( + (component: PlayerLike) => { + let info: PlayerInfo + const isPhantom = component.type == "phantom" + if (isPhantom) { + const origin = getOrigin(component, content.components) + info = { + id: component.id, + team: origin.team, + role: origin.role, + bottomRatio: component.bottomRatio, + rightRatio: component.rightRatio, + ballState: component.ballState, + } + } else { + info = component + } + + return ( + + validatePlayerPosition(component, info, newPos) + } + onRemove={() => doRemovePlayer(component)} + courtRef={courtRef} + availableActions={() => + renderAvailablePlayerActions(info, component) + } + /> + ) + }, + [ + content.components, + doRemovePlayer, + renderAvailablePlayerActions, + validatePlayerPosition, + ], + ) + + const doDeleteAction = useCallback( + (action: Action, idx: number, origin: TacticComponent) => { + setContent((content) => removeAction(origin, action, idx, content)) + }, + [setContent], + ) + + const doUpdateAction = useCallback( + (component: TacticComponent, action: Action, actionIndex: number) => { + setContent((content) => + updateComponent( + { + ...component, + actions: component.actions.toSpliced( + actionIndex, + 1, + action, + ), + }, + content, + ), + ) + }, + [setContent], + ) + + const renderComponent = useCallback( + (component: TacticComponent) => { + if (component.type == "player" || component.type == "phantom") { + return renderPlayer(component) + } + if (component.type == BALL_TYPE) { + return ( + { + setContent((content) => removeBall(content)) + setObjects((objects) => [ + ...objects, + { key: "ball" }, + ]) + }} + /> + ) + } + throw new Error("unknown tactic component " + component) + }, + [renderPlayer, doMoveBall, setContent], + ) + + const renderActions = useCallback( + (component: TacticComponent) => + component.actions.map((action, i) => { + return ( + { + doDeleteAction(action, i, component) + }} + onActionChanges={(action) => + doUpdateAction(component, action, i) + } + /> + ) + }), + [doDeleteAction, doUpdateAction], + ) + + return ( +
+
+
+ +
+
+ { + onNameChange(new_name).then((success) => { + setTitleStyle(success ? {} : ERROR_STYLE) + }) + }, + [onNameChange], + )} + /> +
+
+
+
+
+ + + + overlaps( + courtBounds(), + div.getBoundingClientRect(), + ), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedCourtObject) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ), + [courtBounds, setContent], + )} + render={renderCourtObject} + /> + + +
+
+
+ } + courtRef={courtRef} + previewAction={previewAction} + renderComponent={renderComponent} + renderActions={renderActions} + /> +
+
+
+
+ ) +} + +interface PlayerRackProps { + id: string + objects: RackedPlayer[] + setObjects: (state: RackedPlayer[]) => void + setComponents: ( + f: (components: TacticComponent[]) => TacticComponent[], + ) => void + courtRef: RefObject +} + +function PlayerRack({ + id, + objects, + setObjects, + courtRef, + setComponents, +}: PlayerRackProps) { + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + return ( + overlaps(courtBounds(), div.getBoundingClientRect()), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedPlayer) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]), + [courtBounds, setComponents], + )} + render={useCallback( + ({ team, key }: { team: PlayerTeam; key: string }) => ( + + ), + [], + )} + /> + ) +} + +interface CourtPlayerArrowActionProps { + playerInfo: PlayerInfo + player: PlayerLike + isInvalid: boolean + + content: TacticContent + setContent: (state: SetStateAction) => void + setPreviewAction: (state: SetStateAction) => void + courtRef: RefObject +} + +function CourtPlayerArrowAction({ + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, +}: CourtPlayerArrowActionProps) { + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + return ( + { + const arrowHeadPos = middlePos(headPos) + const targetIdx = getComponentCollided( + headPos, + content.components, + ) + const target = content.components[targetIdx] + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase(arrowHeadPos, courtBounds()), + }, + ], + type: getActionKind(target, playerInfo.ballState).kind, + isInvalid: + !overlaps(headPos, courtBounds()) || + !isActionValid(player, target, content.components), + })) + }} + onHeadPicked={(headPos) => { + ;(document.activeElement as HTMLElement).blur() + + setPreviewAction({ + origin: playerInfo.id, + type: getActionKind(null, playerInfo.ballState).kind, + target: ratioWithinBase(headPos, courtBounds()), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + courtBounds(), + ), + }, + ], + isInvalid: false, + }) + }} + onHeadDropped={(headRect) => { + if (isInvalid) { + setPreviewAction(null) + return + } + + setContent((content) => { + let { createdAction, newContent } = createAction( + player, + courtBounds(), + headRect, + content, + ) + + if (createdAction.type == ActionKind.SHOOT) { + const targetIdx = newContent.components.findIndex( + (c) => c.id == createdAction.target, + ) + newContent = dropBallOnComponent( + targetIdx, + newContent, + false, + ) + const ballState = + player.ballState === BallState.HOLDS_ORIGIN + ? BallState.PASSED_ORIGIN + : BallState.PASSED + newContent = updateComponent( + { + ...(newContent.components.find( + (c) => c.id == player.id, + )! as PlayerLike), + ballState, + }, + newContent, + ) + } + + return newContent + }) + setPreviewAction(null) + }} + /> + ) +} + +function isBallOnCourt(content: TacticContent) { + return ( + content.components.findIndex( + (c) => + ((c.type === "player" || c.type === "phantom") && + (c.ballState === BallState.HOLDS_ORIGIN || + c.ballState === BallState.PASSED_ORIGIN)) || + c.type === BALL_TYPE, + ) != -1 + ) +} + +function renderCourtObject(courtObject: RackedCourtObject) { + if (courtObject.key == "ball") { + return + } + throw new Error("unknown racked court object " + courtObject.key) +} + +function Court({ courtType }: { courtType: string }) { + return ( +
+ {courtType == "PLAIN" ? ( + + ) : ( + + )} +
+ ) +} + +function debounceAsync( + f: (args: A) => Promise, + delay = 1000, +): (args: A) => Promise { + let task = 0 + return (args: A) => { + clearTimeout(task) + return new Promise((resolve, reject) => { + task = setTimeout(() => f(args).then(resolve).catch(reject), delay) + }) + } +} + +function useContentState( + initialContent: S, + initialSaveState: SaveState, + saveStateCallback: (s: S) => Promise, +): [S, Dispatch>, SaveState] { + const [content, setContent] = useState(initialContent) + const [savingState, setSavingState] = useState(initialSaveState) + + const setContentSynced = useCallback( + (newState: SetStateAction) => { + setContent((content) => { + const state = + typeof newState === "function" + ? (newState as (state: S) => S)(content) + : newState + + if (state !== content) { + setSavingState(SaveStates.Saving) + saveStateCallback(state) + .then(setSavingState) + .catch(() => setSavingState(SaveStates.Err)) + } + return state + }) + }, + [saveStateCallback], + ) + + return [content, setContentSynced, savingState] +} diff --git a/front/views/Home.tsx b/src/pages/HomePage.tsx similarity index 81% rename from front/views/Home.tsx rename to src/pages/HomePage.tsx index 65d38f5..f5f51c5 100644 --- a/front/views/Home.tsx +++ b/src/pages/HomePage.tsx @@ -1,26 +1,75 @@ import "../style/home/home.css" -// import AccountSvg from "../assets/account.svg?react" -import { Header } from "./template/Header" import { BASE } from "../Constants" import { MainTitle } from "./component/Title" import { Tactic } from "./model/Tactic" 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" -export default function Home({ +interface Tactic { + 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({ + 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 ( + + ) +} + +function Home({ lastTactics, allTactics, teams, - username, }: { lastTactics: Tactic[] allTactics: Tactic[] teams: Team[] - username: string }) { return (
-
([]) + + const navigate = useNavigate() + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + + const { email, password } = Object.fromEntries( + new FormData(e.target as HTMLFormElement), + ) + + const response = await fetchAPI( + "auth/token", + { email, password }, + "POST", + false, + ) + + if (response.ok) { + const session = getSession() + const { token, expirationDate } = await response.json() + saveSession({ ...session, auth: { token, expirationDate }, urlTarget: undefined }) + startTransition(() => { + navigate(session.urlTarget ?? "/") + }) + return + } + + // if unauthorized : + if (response.status === 401) { + setErrors([ + { + type: "Non autorisé", + messages: [ + "L'adresse email ou le mot de passe sont invalides.", + ], + }, + ]) + return + } + + try { + const failures = await response.json() + setErrors( + Object.entries(failures).map(([type, messages]) => ({ + type, + messages, + })), + ) + } catch (e) { + setErrors([ + { + type: "internal error", + messages: ["an internal error occurred."], + }, + ]) + } + } + + return ( +
+
+

Se connecter

+
+ + {errors.map(({ type, messages }) => + messages.map((message) => ( +

+ {type} : {message} +

+ )), + )} + +
+
+ + + + + + + + Vous n'avez pas de compte ? + +
+
+
+ +
+
+
+
+ ) +} diff --git a/front/views/NewTacticPanel.tsx b/src/pages/NewTacticPage.tsx similarity index 53% rename from front/views/NewTacticPanel.tsx rename to src/pages/NewTacticPage.tsx index d02f314..355a70a 100644 --- a/front/views/NewTacticPanel.tsx +++ b/src/pages/NewTacticPage.tsx @@ -3,9 +3,15 @@ import "../style/new_tactic_panel.css" import plainCourt from "../assets/court/full_court.svg" import halfCourt from "../assets/court/half_court.svg" -import { BASE } from "../Constants" +import { CourtType } from "../model/tactic/Tactic.ts" +import { startTransition, useCallback } from "react" +import { fetchAPI } from "../Fetcher.ts" +import { getSession } from "../api/session.ts" +import { useNavigate } from "react-router-dom" -export default function NewTacticPanel() { +export const DEFAULT_TACTIC_NAME = "Nouvelle tactique" + +export default function NewTacticPage() { return (
@@ -16,12 +22,12 @@ export default function NewTacticPanel() {
@@ -32,16 +38,40 @@ export default function NewTacticPanel() { function CourtKindButton({ name, image, - redirect, + courtType, }: { name: string image: string - redirect: string + courtType: CourtType }) { + + const navigate = useNavigate() + return (
(location.href = BASE + redirect)}> + onClick={useCallback(async () => { + // 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])}>
(null) + const passwordField = useRef(null) + const confirmpasswordField = useRef(null) + const emailField = useRef(null) + + const [errors, setErrors] = useState([]) + + const navigate = useNavigate() + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + + const username = usernameField.current!.value + const password = passwordField.current!.value + const confirmpassword = confirmpasswordField.current!.value + const email = emailField.current!.value + + if (confirmpassword !== password) { + setErrors([ + { + type: "password", + messages: [ + "le mot de passe et la confirmation du mot de passe ne sont pas equivalent.", + ], + }, + ]) + return + } + + const response = await fetchAPI("auth/register", { + username, + password, + email, + }) + + if (response.ok) { + const { token, expirationDate } = await response.json() + const session = getSession() + saveSession({ ...session, auth: { token, expirationDate }, urlTarget: undefined }) + startTransition(() => { + navigate(session.urlTarget ?? "/") + }) + return + } + + try { + const failures = await response.json() + setErrors( + Object.entries(failures).map(([type, messages]) => ({ + type, + messages, + })), + ) + } catch (e) { + setErrors([ + { + type: "internal error", + messages: ["an internal error occurred."], + }, + ]) + } + } + + return ( +
+
+

S'enregistrer

+
+ +
+ {errors.map(({ type, messages }) => + messages.map((message) => ( +

+ {type} : {message} +

+ )), + )} +
+ +
+
+ + + + + + + + + + + + + + + + Vous avez déjà un compte ? + +
+
+ +
+
+
+ ) +} diff --git a/front/views/Settings.tsx b/src/pages/Settings.tsx similarity index 100% rename from front/views/Settings.tsx rename to src/pages/Settings.tsx diff --git a/front/views/TeamPanel.tsx b/src/pages/TeamPanel.tsx similarity index 88% rename from front/views/TeamPanel.tsx rename to src/pages/TeamPanel.tsx index 709d7f2..eb35239 100644 --- a/front/views/TeamPanel.tsx +++ b/src/pages/TeamPanel.tsx @@ -1,9 +1,28 @@ import "../style/team_panel.css" import { BASE } from "../Constants" -import { Team, TeamInfo, Member } from "../model/Team" -import { User } from "../model/User" +import { Member, Team, TeamInfo } from "../model/Team" +import { useParams } from "react-router-dom" -export default function TeamPanel({ +export default function TeamPanelPage() { + 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 ( + + ) +} + +function TeamPanel({ isCoach, team, currentUserId, diff --git a/src/pages/Visualizer.tsx b/src/pages/Visualizer.tsx new file mode 100644 index 0000000..6f3b67a --- /dev/null +++ b/src/pages/Visualizer.tsx @@ -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({}) +// +// return ( +//
+//
+//

{name}

+//
+//
+// Basketball Court +//
+//
+// ) +// } diff --git a/front/views/component/Title.tsx b/src/pages/component/Title.tsx similarity index 100% rename from front/views/component/Title.tsx rename to src/pages/component/Title.tsx diff --git a/front/views/model/Tactic.tsx b/src/pages/model/Tactic.tsx similarity index 100% rename from front/views/model/Tactic.tsx rename to src/pages/model/Tactic.tsx diff --git a/front/views/model/Team.tsx b/src/pages/model/Team.tsx similarity index 100% rename from front/views/model/Team.tsx rename to src/pages/model/Team.tsx diff --git a/front/views/model/User.tsx b/src/pages/model/User.tsx similarity index 100% rename from front/views/model/User.tsx rename to src/pages/model/User.tsx diff --git a/src/pages/template/Header.tsx b/src/pages/template/Header.tsx new file mode 100644 index 0000000..4316098 --- /dev/null +++ b/src/pages/template/Header.tsx @@ -0,0 +1,73 @@ +import AccountSvg from "../../assets/account.svg?react" +import "../../style/template/header.css" +import { startTransition, useEffect, useState } from "react" +import { fetchAPIGet } from "../../Fetcher.ts" +import { getSession, saveSession } from "../../api/session.ts" +import { useNavigate } from "react-router-dom" + +export function Header() { + const session = getSession() + const [username, setUsername] = useState( + session.username ?? null, + ) + + const navigate = useNavigate() + + useEffect(() => { + async function loadUsername() { + const response = await fetchAPIGet("user", false) + + if (response.status == 401) { + //if unauthorized + return + } + + //TODO check if the response is ok and handle errors + const { name: username } = await response.json() + saveSession({ ...session, username }) + setUsername(username) + } + + // if the user is authenticated and the username is not already present in the session, + if (session.auth && !session.username) loadUsername() + }, [session]) + + return ( + + ) +} diff --git a/front/style/actions/arrow_action.css b/src/style/actions/arrow_action.css similarity index 92% rename from front/style/actions/arrow_action.css rename to src/style/actions/arrow_action.css index 3aa88d7..77bfa4c 100644 --- a/front/style/actions/arrow_action.css +++ b/src/style/actions/arrow_action.css @@ -5,6 +5,7 @@ .arrow-action-icon { user-select: none; -moz-user-select: none; + -webkit-user-drag: none; max-width: 17px; max-height: 17px; } diff --git a/front/style/actions/remove_action.css b/src/style/actions/remove_action.css similarity index 100% rename from front/style/actions/remove_action.css rename to src/style/actions/remove_action.css diff --git a/src/style/app.css b/src/style/app.css new file mode 100644 index 0000000..38b4ffc --- /dev/null +++ b/src/style/app.css @@ -0,0 +1,6 @@ +#app { + height: 100vh; + width: 100vw; + display: flex; + flex-direction: column; +} diff --git a/front/style/ball.css b/src/style/ball.css similarity index 100% rename from front/style/ball.css rename to src/style/ball.css diff --git a/front/style/bendable_arrows.css b/src/style/bendable_arrows.css similarity index 100% rename from front/style/bendable_arrows.css rename to src/style/bendable_arrows.css diff --git a/front/style/editor.css b/src/style/editor.css similarity index 99% rename from front/style/editor.css rename to src/style/editor.css index 5ba7596..b6a8ea4 100644 --- a/front/style/editor.css +++ b/src/style/editor.css @@ -23,6 +23,7 @@ } #topbar-div { + width: 100%; display: flex; background-color: var(--main-color); margin-bottom: 3px; diff --git a/src/style/form.css b/src/style/form.css new file mode 100644 index 0000000..c950f7d --- /dev/null +++ b/src/style/form.css @@ -0,0 +1,67 @@ +body { + font-family: Arial, sans-serif; + background-color: #f1f1f1; +} + +.container { + max-width: 400px; + margin: 0 auto; + padding: 20px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +h2 { + text-align: center; +} + +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 5px; +} + +input[type="text"], +input[type="password"] { + width: 95%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; +} + +.error-messages { + color: #ff331a; + font-style: italic; +} + +.inscr { + font-size: small; + text-align: right; +} + +.consentement { + font-size: small; +} + +#buttons { + display: flex; + justify-content: center; + padding: 10px 20px; +} + +.button { + background-color: #007bff; + color: #fff; + padding: 10px 20px; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.button:hover { + background-color: #0056b3; +} diff --git a/front/style/home/home.css b/src/style/home/home.css similarity index 89% rename from front/style/home/home.css rename to src/style/home/home.css index 57e7101..991285d 100644 --- a/front/style/home/home.css +++ b/src/style/home/home.css @@ -1,4 +1,4 @@ -@import url(../theme/dark.css); +@import url(../theme/default.css); @import url(personnal_space.css); @import url(side_menu.css); @import url(../component/header.css); @@ -21,7 +21,7 @@ .data { border: 1.5px solid var(--main-contrast-color); - background-color: var(--main-color); + background-color: var(--home-main-color); border-radius: 0.75cap; color: var(--main-contrast-color); } diff --git a/front/style/home/personnal_space.css b/src/style/home/personnal_space.css similarity index 100% rename from front/style/home/personnal_space.css rename to src/style/home/personnal_space.css diff --git a/front/style/home/side_menu.css b/src/style/home/side_menu.css similarity index 81% rename from front/style/home/side_menu.css rename to src/style/home/side_menu.css index 3a23947..aeb50b4 100644 --- a/front/style/home/side_menu.css +++ b/src/style/home/side_menu.css @@ -1,7 +1,7 @@ -@import url(../theme/dark.css); +@import url(../theme/default.css); #side-menu { - background-color: var(--third-color); + background-color: var(--home-third-color); display: flex; flex-direction: column; align-items: center; @@ -17,7 +17,7 @@ width: 90%; } .titre-side-menu { - border-bottom: var(--main-color) solid 3px; + border-bottom: var(--home-main-color) solid 3px; width: 100%; margin-bottom: 3%; } @@ -28,7 +28,7 @@ color: var(--main-contrast-color); letter-spacing: 1px; text-transform: uppercase; - background-color: var(--main-color); + background-color: var(--home-main-color); padding: 3%; margin-bottom: 0px; margin-right: 3%; diff --git a/front/style/new_tactic_panel.css b/src/style/new_tactic_panel.css similarity index 100% rename from front/style/new_tactic_panel.css rename to src/style/new_tactic_panel.css diff --git a/front/style/player.css b/src/style/player.css similarity index 97% rename from front/style/player.css rename to src/style/player.css index 22afe4e..b03123b 100644 --- a/front/style/player.css +++ b/src/style/player.css @@ -2,6 +2,10 @@ pointer-events: none; } +.phantom { + opacity: 50%; +} + .player-content { display: flex; flex-direction: column; diff --git a/src/style/steps_tree.css b/src/style/steps_tree.css new file mode 100644 index 0000000..eadeaf6 --- /dev/null +++ b/src/style/steps_tree.css @@ -0,0 +1,87 @@ +.step-piece { + position: relative; + font-family: monospace; + pointer-events: all; + + background-color: var(--editor-tree-step-piece); + color: var(--selected-team-secondarycolor); + + border-radius: 100px; + + width: 20px; + height: 20px; + + display: flex; + + align-items: center; + justify-content: center; + + user-select: none; + cursor: pointer; + + border: 2px solid var(--editor-tree-background); +} + +.step-piece-selected { + border: 2px solid var(--selection-color-light); +} + +.step-piece-selected, +.step-piece:focus, +.step-piece:hover { + background-color: var(--editor-tree-step-piece-hovered); +} + +.step-piece-actions { + display: none; + position: absolute; + column-gap: 5px; + top: -140%; +} + +.step-piece-selected .step-piece-actions { + display: flex; +} + +.add-icon, +.remove-icon { + background-color: white; + border-radius: 100%; +} + +.add-icon { + fill: var(--add-icon-fill); +} + +.remove-icon { + fill: var(--remove-icon-fill); +} + +.step-children { + margin-top: 10vh; + display: flex; + flex-direction: row; + width: 100%; + height: 100%; +} + +.step-group { + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + + width: 100%; + height: 100%; +} + +.steps-tree { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 10%; + + height: 100%; +} diff --git a/front/style/team_panel.css b/src/style/team_panel.css similarity index 100% rename from front/style/team_panel.css rename to src/style/team_panel.css diff --git a/src/style/template/header.css b/src/style/template/header.css new file mode 100644 index 0000000..46607aa --- /dev/null +++ b/src/style/template/header.css @@ -0,0 +1,61 @@ +@import url(../theme/default.css); + +#header { + text-align: center; + background-color: var(--home-main-color); + margin: 0; + + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + font-family: var(--font-title); +} + +#img-account { + cursor: pointer; +} + +#header-left, +#header-right, +#header-center { + width: 100%; +} + +#header-right { + display: flex; + flex-direction: column; + justify-content: center; + align-items: end; +} + +#username { + color: var(--main-contrast-color); + margin: 0 0 0 10px; +} + +#clickable-header-right:hover #username { + color: var(--accent-color); +} + +#clickable-header-right { + border-radius: 1cap; + padding: 2%; + + margin-right: 20px; + display: flex; + align-items: center; + justify-content: space-evenly; +} + +.clickable { + cursor: pointer; +} + +#iqball { + margin: 0; + color: var(--accent-color); + font-weight: bold; + font-size: 35px; +} diff --git a/front/style/theme/default.css b/src/style/theme/default.css similarity index 74% rename from front/style/theme/default.css rename to src/style/theme/default.css index 1885746..caa5162 100644 --- a/front/style/theme/default.css +++ b/src/style/theme/default.css @@ -21,4 +21,12 @@ --player-piece-ball-border-color: #000000; --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; } diff --git a/front/style/title_input.css b/src/style/title_input.css similarity index 100% rename from front/style/title_input.css rename to src/style/title_input.css diff --git a/front/style/visualizer.css b/src/style/visualizer.css similarity index 100% rename from front/style/visualizer.css rename to src/style/visualizer.css diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json index d01f3cc..bb336e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,26 @@ { - "compilerOptions": { - "target": "es2021", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "types": ["vite/client", "vite-plugin-svgr/client"], - "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx" - }, - "include": [ - "front" - ] + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client", "vite-plugin-svgr/client"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..6675f46 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/verify.sh b/verify.sh deleted file mode 100755 index 314b8bc..0000000 --- a/verify.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -## verify php and typescript types - -echo "running php typechecking" -vendor/bin/phpstan analyze && echo "php types are respected" - -echo "running typescript typechecking" -npm run tsc && echo "typescript types are respected" \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 4ff1dc5..214327e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,47 +1,18 @@ -import {defineConfig} from "vite"; -import react from '@vitejs/plugin-react'; -import fs from "fs"; -import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; -import svgr from "vite-plugin-svgr"; - - -function resolve_entries(dirname: string): [string, string][] { - - //exclude assets - if (dirname == "front/assets" || dirname == "front/style") { - return [] - } - - return fs.readdirSync(dirname).flatMap(file_name => { - if (fs.lstatSync(`${dirname}/${file_name}`).isFile()) { - return [[`${dirname}/${file_name}`, `${dirname}/${file_name}`]] - } else { - return resolve_entries(`${dirname}/${file_name}`) - } - }) -} +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react" +import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js" +import svgr from "vite-plugin-svgr" +// https://vitejs.dev/config/ export default defineConfig({ - root: 'front', - base: '/front', - envDir: '..', build: { - target: 'es2021', - assetsDir: '', - outDir: "../dist", - manifest: true, - rollupOptions: { - input: Object.fromEntries(resolve_entries("front")), - preserveEntrySignatures: "allow-extension", - } + target: "es2021", }, plugins: [ react(), cssInjectedByJsPlugin({ relativeCSSInjection: true, }), - svgr({ - include: "**/*.svg?react" - }) - ] + svgr(), + ], })