diff --git a/.gitignore b/.gitignore index 9124809..61df6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ .vite vendor - +.nfs* composer.lock *.phar /dist @@ -38,3 +38,5 @@ package-lock.json npm-debug.log* yarn-debug.log* yarn-error.log* + +.php-cs-fixer.cache \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..77ef0e7 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..7db0434 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "bracketSameLine": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 4, + "semi": false +} \ No newline at end of file diff --git a/Documentation/Conception.md b/Documentation/Conception.md new file mode 100644 index 0000000..68b4cd9 --- /dev/null +++ b/Documentation/Conception.md @@ -0,0 +1,123 @@ +# Conception + +## Organisation + +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. + +## 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. + +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. + +```php +public function doPostAction(array $form) { + $failures = []; + $req = new HttpRequest($form); + $email = $req['email'] ?? null; + if ($email == null) { + $failures[] = "Vous n'avez pas entré d'adresse email."; + return; + } + if (Validation::isEmail($email)) { + $failures[] = "Format d'adresse email invalide."; + } + 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 ... +} +``` + +Nous sommes obligés de tester à la main la présence des champs dans la requête, et nous avons une paire condition/erreur par type de validation, +ce qui, pour des requêtes avec plusieurs champs, peut vite devenir illisible si nous voulons être précis sur les erreurs. + +Ici, une validation est une règle, un prédicat qui permet de valider une donnée sur un critère bien précis (injection html, adresse mail, longueur, etc.). +Bien souvent, lorsque le prédicat échoue, un message est ajouté à la liste des erreurs rencontrés, mais ce message est souvent le même, ce qui rajoute en plus +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 : + +```php +public function doPostAction(array $form): HttpResponse { + $failures = []; + $req = HttpRequest::from($form, $failures, [ + 'email' => [Validators::email(), Validators::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 +champs que celle-ci contient. + +Nous pouvons ensuite emballer les erreurs de validation dans des `ValidationFail` et `FieldValidationFail`, ce qui permet ensuite d'obtenir +plus de précision sur une erreur, comme le nom du champ qui est invalidé, et qui permet ensuite à nos vues de pouvoir manipuler plus facilement +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. + +`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. + +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, +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, +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/README.md b/Documentation/README.md new file mode 100644 index 0000000..dfc91c7 --- /dev/null +++ b/Documentation/README.md @@ -0,0 +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 diff --git a/Documentation/conception.puml b/Documentation/conception.puml deleted file mode 100644 index 06ae256..0000000 --- a/Documentation/conception.puml +++ /dev/null @@ -1,12 +0,0 @@ -@startuml - -class Connexion - -class Modele - -class Account - -class AccountGateway - - -@enduml diff --git a/Documentation/database_mcd.puml b/Documentation/database_mcd.puml index e698a69..710dfee 100644 --- a/Documentation/database_mcd.puml +++ b/Documentation/database_mcd.puml @@ -2,12 +2,10 @@ object Account { id - name - age email - phoneNumber - passwordHash - profilePicture + username + token + hash } object Team { @@ -26,7 +24,7 @@ object TacticFolder { object Tactic { id_json name - creationDate + creation_date } usecase have_team [ @@ -63,6 +61,10 @@ usecase contains_other_folder [ to contain ] +usecase owns [ + owns +] + Account "0,n" -- have_team have_team -- "1,n" Team @@ -73,6 +75,9 @@ shared_tactic_account -- "0,n" Tactic Tactic "0,n" -- shared_tactic_team shared_tactic_team -- "0,n" Team +Tactic "1,1" -- owns +owns -- Account + Team "0,n" -- shared_folder_team shared_folder_team -- "0,n"TacticFolder diff --git a/Documentation/how-to-dev.md b/Documentation/how-to-dev.md index 0af8da5..39435e6 100644 --- a/Documentation/how-to-dev.md +++ b/Documentation/how-to-dev.md @@ -28,7 +28,7 @@ If we take a look at the request, we'll see that the url does not targets `local `localhost:5173` is the react development server, it is able to serve our react front view files. Let's run the react development server. -It is a simple as running `npm start` in a new terminal (be sure to run it in the repository's directory). +It is as simple as running `npm start` in a new terminal (be sure to run it in the repository's directory). ![](assets/npm-start.png) You should see something like this, it says that the server was opened on port `5173`, thats our react development server ! @@ -40,7 +40,7 @@ Caution: **NEVER** directly connect on the `localhost:5173` node development ser # How it works 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 is our gateway, it uses an `AltoRouter` that dispatches the request's process to a controller. +If you look at our `index.php` (located in `/public` folder), you'll see that it define our routes, it uses an `AltoRouter` then delegates the request's action processing to a controller. We can see that there are two registered routes, the `GET:/` (that then calls `SampleFormController#displayForm()`) and `POST:/result` (that calls `SampleFormController#displayResults()`). 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. @@ -115,7 +115,7 @@ function _asset(string $assetURI): string { The simplest profile, simply redirect all assets to the development server ### Production profile -Before the CD deploys the generated files to the server, +Before the CD workflow step deploys the generated files to the server, it generates a `/views-mappings.php` file that will map the react views file names to their compiled javascript files : ```php @@ -137,13 +137,4 @@ function _asset(string $assetURI): string { // fallback to the uri itself. return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI); } -``` - -## React views conventions. -Conventions regarding our react views __must be respected in order to be renderable__. - -### The `render(any)` function -Any React view component needs to be default exported in order to be imported and used from PHP. Those components will receive as props the arguments that the PHP server has transmitted. -The `arguments` parameter is used to pass data to the react component. - -If you take a look at the `front/views/SampleForm.tsx` view, here's the definition of its render function : +``` \ No newline at end of file diff --git a/Documentation/http.puml b/Documentation/http.puml index b41135d..2f8e22b 100644 --- a/Documentation/http.puml +++ b/Documentation/http.puml @@ -35,7 +35,7 @@ class ViewHttpResponse extends HttpResponse { - arguments: array - kind: int - + __construct(kind: int, file: string, arguments: array, code: int = HttpCodes::OK) + - __construct(kind: int, file: string, arguments: array, code: int = HttpCodes::OK) + getViewKind(): int + getFile(): string + getArguments(): array @@ -44,4 +44,8 @@ class ViewHttpResponse extends HttpResponse { + react(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse } +note right of ViewHttpResponse + Into src/App +end note + @enduml \ No newline at end of file diff --git a/Documentation/models.puml b/Documentation/models.puml index 0ad5135..86ca699 100755 --- a/Documentation/models.puml +++ b/Documentation/models.puml @@ -1,42 +1,38 @@ @startuml -class Account { - - email: String - - phoneNumber: String +class TacticInfo { - id: int - - + setMailAddress(String) - + getMailAddress(): String - + getPhoneNumber(): String - + setPhoneNumber(String) - + getUser(): AccountUser + - name: string + - creationDate: string + - ownerId: string + - content: string + + getId(): int + + getOwnerId(): int + + getCreationTimestamp(): int + + getName(): string + + getContent(): string } -Account --> "- user" AccountUser -Account --> "- teams *" Team - -interface User { - + getName(): String - + getProfilePicture(): Url - + getAge(): int -} - -class AccountUser { - - name: String - - profilePicture: Url - - age: int +class Account { + - email: string + - token: string + - name: string + - id: int - + setName(String) - + setProfilePicture(URI) - + setAge(int) + + getMailAddress(): string + + getToken(): string + + getName(): string + + getId(): int } -AccountUser ..|> User class Member { - userId: int + - teamId: int + + __construct(role : MemberRole) + getUserId(): int + + getTeamId(): int + getRole(): MemberRole } @@ -47,20 +43,28 @@ enum MemberRole { COACH } -class Team { - - name: String - - picture: Url - - members: array - + getName(): String - + getPicture(): Url +class TeamInfo { + - creationDate: int + - name: string + - picture: string + + + getName(): string + + getPicture(): string + getMainColor(): Color + getSecondColor(): Color - + listMembers(): array } -Team --> "- mainColor" Color -Team --> "- secondaryColor" Color +TeamInfo --> "- mainColor" Color +TeamInfo --> "- secondaryColor" Color + +class Team { + getInfo(): TeamInfo + listMembers(): Member[] +} + +Team --> "- info" TeamInfo +Team --> "- members *" Member class Color { - value: int diff --git a/Documentation/mvc/auth.puml b/Documentation/mvc/auth.puml new file mode 100644 index 0000000..8a2bb1f --- /dev/null +++ b/Documentation/mvc/auth.puml @@ -0,0 +1,32 @@ +@startuml + +class AuthController { + +__construct (model : AuthModel) + + displayRegister() : HttpResponse + + register(request : array,session : MutableSessionHandle) : HttpResponse + + displayLogin() : HttpResponse + + login(request : array , session : MutableSessionHandle) : HttpResponse +} +AuthController --> "- model" AuthModel + +class AuthModel { + +__construct(gateway : AccountGateway) + + register(username : string, password : string, confirmPassword : string, email : string, failures : array): Account + + generateToken() : string + + login(email : string, password : string) +} +AuthModel --> "- gateway" AccountGateway + +class AccountGateway { + -con : Connection + +__construct(con : Connection) + + insertAccount(name : string, email : string, hash : string, token : string) : int + + getRowsFromMail(email : string): array + + getHash(email : string) : array + + exists(email : string) : bool + + getAccountFromMail(email : string ): Account + + getAccountFromToken(email : string ): Account + +} + +@enduml \ No newline at end of file diff --git a/Documentation/mvc/team.puml b/Documentation/mvc/team.puml new file mode 100644 index 0000000..ad5e201 --- /dev/null +++ b/Documentation/mvc/team.puml @@ -0,0 +1,63 @@ +@startuml +class Team { + - name: string + - picture: Url + - members: array + + + __construct(name : string, picture : string, mainColor : Colo, secondColor : Color) + + getName(): string + + getPicture(): Url + + getMainColor(): Color + + getSecondColor(): Color + + listMembers(): array +} + +Team --> "- mainColor" Color +Team --> "- secondColor" Color + +class Color { + - value: string + - __construct(value : string) + + getValue(): string + + from(value: string): Color + + tryFrom(value : string) : ?Color +} + +class TeamGateway{ + -- + + __construct(con : Connexion) + + insert(name : string ,picture : string, mainColor : Color, secondColor : Color) + + listByName(name : string): array +} + +TeamGateway *--"- con" Connexion +TeamGateway ..> Color + +class TeamModel{ + --- + + __construct(gateway : TeamGateway) + + createTeam(name : string,picture : string, mainColorValue : int, secondColorValue : int, errors : array) + + listByName(name : string ,errors : array) : ?array + + displayTeam(id : int): Team +} + +TeamModel *--"- gateway" TeamGateway +TeamModel ..> Team +TeamModel ..> Color + +class TeamController{ + - twig : Environement + -- + + __construct( model : TeamModel, twig : Environement) + + displaySubmitTeam() : HttpResponse + + submitTeam(request : array) : HttpResponse + + displayListTeamByName(): HttpResponse + + listTeamByName(request : array) : HttpResponse + + displayTeam(id : int): HttpResponse +} + +TeamController *--"- model" TeamModel + +class Connexion { } + +@enduml \ No newline at end of file diff --git a/Documentation/validation.puml b/Documentation/validation.puml index dd0cafe..f509cf4 100644 --- a/Documentation/validation.puml +++ b/Documentation/validation.puml @@ -50,9 +50,11 @@ class Validation { } class Validators { + --- + nonEmpty(): Validator + shorterThan(limit: int): Validator + userString(maxLen: int): Validator + ... } diff --git a/README.md b/README.md index d2edce6..9a0df84 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # IQBall - Web Application This repository hosts the IQBall application for web +## Read the docs ! +You can find some additional documentation in the [Documentation](Documentation) folder, +and in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki). + diff --git a/ci/.drone.yml b/ci/.drone.yml index d6c474c..8b7058d 100644 --- a/ci/.drone.yml +++ b/ci/.drone.yml @@ -1,6 +1,6 @@ kind: pipeline type: docker -name: "Deploy on maxou.dev" +name: "CI and Deploy on maxou.dev" volumes: - name: server @@ -11,11 +11,26 @@ trigger: - push steps: + + - image: node:latest + name: "front CI" + commands: + - npm install + - npm run tsc + + - image: composer:latest + name: "php CI" + commands: + - composer install + - vendor/bin/phpstan analyze + - image: node:latest name: "build node" volumes: &outputs - name: server path: /outputs + depends_on: + - "front CI" commands: - curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh - chmod +x /tmp/moshell_setup.sh @@ -24,14 +39,15 @@ steps: - - /root/.local/bin/moshell ci/build_react.msh - - image: composer:latest + - image: ubuntu:latest name: "prepare php" volumes: *outputs + depends_on: + - "php CI" commands: - mkdir -p /outputs/public # this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file. - sed -iE 's/\\/\\*PROFILE_FILE\\*\\/\\s*".*"/"profiles\\/prod-config-profile.php"/' config.php - - composer install && composer update - rm profiles/dev-config-profile.php - mv src config.php sql profiles vendor /outputs/ diff --git a/ci/build_react.msh b/ci/build_react.msh index c893498..32a5923 100755 --- a/ci/build_react.msh +++ b/ci/build_react.msh @@ -3,13 +3,14 @@ mkdir -p /outputs/public apt update && apt install jq -y -npm install val drone_branch = std::env("DRONE_BRANCH").unwrap() val base = "/IQBall/$drone_branch/public" npm run build -- --base=$base --mode PROD +npm run build -- --base=/IQBall/public --mode PROD + // Read generated mappings from build val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json) val mappings = $result.split('\n') @@ -29,6 +30,5 @@ echo "];" >> views-mappings.php chmod +r views-mappings.php -// moshell does not supports file patterns -bash <<< "mv dist/* public/* front/assets/ front/style/ /outputs/public/" +mv dist/* front/assets/ front/style/ public/* /outputs/public/ mv views-mappings.php /outputs/ diff --git a/composer.json b/composer.json index e5b80e0..1d3a4d7 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "autoload": { "psr-4": { - "App\\": "src/" + "IQBall\\": "src/" } }, "require": { @@ -11,5 +11,8 @@ "ext-pdo_sqlite": "*", "twig/twig":"^2.0", "phpstan/phpstan": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.38" } -} \ No newline at end of file +} diff --git a/config.php b/config.php index 592ee38..6e510c8 100644 --- a/config.php +++ b/config.php @@ -5,7 +5,7 @@ // Please do not touch. require /*PROFILE_FILE*/ "profiles/dev-config-profile.php"; -CONST SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH; +const SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH; /** * Maps the given relative source uri (relative to the `/front` folder) to its actual location depending on imported profile. @@ -20,4 +20,3 @@ global $_data_source_name; $data_source_name = $_data_source_name; const DATABASE_USER = _DATABASE_USER; const DATABASE_PASSWORD = _DATABASE_PASSWORD; - diff --git a/format.sh b/format.sh new file mode 100755 index 0000000..a9ff1c2 --- /dev/null +++ b/format.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +## verify php and typescript types + +echo "formatting php typechecking" +vendor/bin/php-cs-fixer fix + +echo "formatting typescript typechecking" +npm run format diff --git a/front/Constants.ts b/front/Constants.ts index aaaaa43..76b37c2 100644 --- a/front/Constants.ts +++ b/front/Constants.ts @@ -1,4 +1,4 @@ /** * This constant defines the API endpoint. */ -export const API = import.meta.env.VITE_API_ENDPOINT; \ No newline at end of file +export const API = import.meta.env.VITE_API_ENDPOINT diff --git a/front/Fetcher.ts b/front/Fetcher.ts new file mode 100644 index 0000000..4c483e9 --- /dev/null +++ b/front/Fetcher.ts @@ -0,0 +1,16 @@ +import { API } from "./Constants" + +export function fetchAPI( + url: string, + payload: unknown, + method = "POST", +): Promise { + return fetch(`${API}/${url}`, { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) +} diff --git a/front/Utils.ts b/front/Utils.ts new file mode 100644 index 0000000..523d813 --- /dev/null +++ b/front/Utils.ts @@ -0,0 +1,12 @@ +export function calculateRatio( + it: { x: number; y: number }, + parent: DOMRect, +): { x: number; y: number } { + const relativeXPixels = it.x - parent.x + const relativeYPixels = it.y - parent.y + + const xRatio = relativeXPixels / parent.width + const yRatio = relativeYPixels / parent.height + + return { x: xRatio, y: yRatio } +} diff --git a/front/ViewRenderer.tsx b/front/ViewRenderer.tsx index ffaf886..57f2e34 100644 --- a/front/ViewRenderer.tsx +++ b/front/ViewRenderer.tsx @@ -1,5 +1,5 @@ -import ReactDOM from "react-dom/client"; -import React, {FunctionComponent} from "react"; +import ReactDOM from "react-dom/client" +import React, { FunctionComponent } from "react" /** * Dynamically renders a React component, with given arguments @@ -8,12 +8,12 @@ import React, {FunctionComponent} from "react"; */ export function renderView(Component: FunctionComponent, args: {}) { const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement - ); + document.getElementById("root") as HTMLElement, + ) root.render( - - - ); -} \ No newline at end of file + + , + ) +} diff --git a/front/assets/icon/account.png b/front/assets/icon/account.png new file mode 100644 index 0000000..6ed3299 Binary files /dev/null and b/front/assets/icon/account.png differ diff --git a/front/assets/icon/account.svg b/front/assets/icon/account.svg new file mode 100644 index 0000000..ce59194 --- /dev/null +++ b/front/assets/icon/account.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/components/Rack.tsx b/front/components/Rack.tsx index 415ba2f..178ef24 100644 --- a/front/components/Rack.tsx +++ b/front/components/Rack.tsx @@ -1,58 +1,72 @@ -import {ReactElement, useRef} from "react"; -import Draggable from "react-draggable"; +import { ReactElement, useRef } from "react" +import Draggable from "react-draggable" -export interface RackProps { - id: string, - objects: E[], - onChange: (objects: E[]) => void, - canDetach: (ref: HTMLDivElement) => boolean, - onElementDetached: (ref: HTMLDivElement, el: E) => void, - render: (e: E) => ReactElement, +export interface RackProps { + id: string + objects: E[] + onChange: (objects: E[]) => void + canDetach: (ref: HTMLDivElement) => boolean + onElementDetached: (ref: HTMLDivElement, el: E) => void + render: (e: E) => ReactElement } -interface RackItemProps { - item: E, - onTryDetach: (ref: HTMLDivElement, el: E) => void, - render: (e: E) => ReactElement, +interface RackItemProps { + item: E + onTryDetach: (ref: HTMLDivElement, el: E) => void + render: (e: E) => ReactElement } /** * A container of draggable objects * */ -export function Rack({id, objects, onChange, canDetach, onElementDetached, render}: RackProps) { +export function Rack({ + id, + objects, + onChange, + canDetach, + onElementDetached, + render, + }: RackProps) { return ( -
- {objects.map(element => ( - { - if (!canDetach(ref)) - return +
+ {objects.map((element) => ( + { + if (!canDetach(ref)) return - const index = objects.findIndex(o => o.key === element.key) - onChange(objects.toSpliced(index, 1)) + const index = objects.findIndex( + (o) => o.key === element.key, + ) + onChange(objects.toSpliced(index, 1)) - onElementDetached(ref, element) - }}/> + onElementDetached(ref, element) + }} + /> ))}
) } -function RackItem({item, onTryDetach, render}: RackItemProps) { - const divRef = useRef(null); +function RackItem({ + item, + onTryDetach, + render, + }: RackItemProps) { + const divRef = useRef(null) return ( onTryDetach(divRef.current!, item)}> -
- {render(item)} -
+
{render(item)}
) -} \ No newline at end of file +} diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index eb162d1..8da1c65 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -1,28 +1,32 @@ -import React, {CSSProperties, useRef, useState} from "react"; -import "../style/title_input.css"; +import React, { CSSProperties, useRef, useState } from "react" +import "../style/title_input.css" export interface TitleInputOptions { - style: CSSProperties, - default_value: string, + style: CSSProperties + default_value: string on_validated: (a: string) => void } -export default function TitleInput({style, default_value, on_validated}: TitleInputOptions) { - const [value, setValue] = useState(default_value); - const ref = useRef(null); +export default function TitleInput({ + style, + default_value, + on_validated, +}: TitleInputOptions) { + const [value, setValue] = useState(default_value) + const ref = useRef(null) return ( - setValue(event.target.value)} - onBlur={_ => on_validated(value)} - onKeyDown={event => { - if (event.key == 'Enter') - ref.current?.blur(); - }} + setValue(event.target.value)} + onBlur={(_) => on_validated(value)} + onKeyDown={(event) => { + if (event.key == "Enter") ref.current?.blur() + }} /> ) -} \ No newline at end of file +} diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 607ad38..b583f61 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,35 +1,37 @@ -import CourtSvg from '../../assets/basketball_court.svg'; -import '../../style/basket_court.css'; -import {ReactElement, useEffect, useRef, useState} from "react"; -import CourtPlayer from "./CourtPlayer"; -import {Player} from "../../data/Player"; -export function BasketCourt({players, onPlayerRemove}: { players: Player[], onPlayerRemove: (Player) => void }) { - const [courtPlayers, setCourtPlayers] = useState([]) - const divRef = useRef(null); +import CourtSvg from "../../assets/basketball_court.svg?react" +import "../../style/basket_court.css" +import { useRef } from "react" +import CourtPlayer from "./CourtPlayer" +import { Player } from "../../tactic/Player" - useEffect(() => { - const bounds = divRef.current!.getBoundingClientRect(); - setCourtPlayers(players.map(player => { - return ( - onPlayerRemove(player)} - /> - ) - })) - }, [players, divRef]); +export interface BasketCourtProps { + players: Player[] + onPlayerRemove: (p: Player) => void + onPlayerChange: (p: Player) => void +} + +export function BasketCourt({ + players, + onPlayerRemove, + onPlayerChange, +}: BasketCourtProps) { + const divRef = useRef(null) return ( -
- - {courtPlayers} +
+ + {players.map((player) => { + return ( + onPlayerRemove(player)} + parentRef={divRef} + /> + ) + })}
) } - - diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 21a088e..6431a50 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,55 +1,77 @@ -import {useRef} from "react"; -import "../../style/player.css"; -import RemoveIcon from "../../assets/icon/remove.svg"; -import AssignBallIcon from "../../assets/icon/ball.svg"; -import Draggable, {DraggableBounds} from "react-draggable"; -import {PlayerPiece} from "./PlayerPiece"; +import { RefObject, useRef, useState } from "react" +import "../../style/player.css" +import RemoveIcon from "../../assets/icon/remove.svg?react" +import BallIcon from "../../assets/icon/ball.svg?react" +import Draggable from "react-draggable" +import { PlayerPiece } from "./PlayerPiece" +import { Player } from "../../tactic/Player" +import { calculateRatio } from "../../Utils" export interface PlayerProps { - pos: string, - team: string, - x: number, - y: number, - bounds: DraggableBounds, - onRemove: () => void, - hasBall: boolean + player: Player + onChange: (p: Player) => void + onRemove: () => void + parentRef: RefObject } /** * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ -export default function CourtPlayer({pos, team, x, y, bounds, onRemove, hasBall}: PlayerProps) { +export default function CourtPlayer({ + player, + onChange, + onRemove, + assignBall, + parentRef, + }: PlayerProps) { + const pieceRef = useRef(null) + + const x = player.rightRatio + const y = player.bottomRatio - const ref = useRef(null); return ( -
+ nodeRef={pieceRef} + bounds="parent" + position={{ x, y }} + onStop={() => { + const pieceBounds = pieceRef.current!.getBoundingClientRect() + const parentBounds = parentRef.current!.getBoundingClientRect() + + const { x, y } = calculateRatio(pieceBounds, parentBounds) -
{ - if (e.key == "Delete") - onRemove() - }}> + onChange({ + rightRatio: x, + bottomRatio: y, + team: player.team, + role: player.role, + hasBall: false + }) + }}> +
+
{ + if (e.key == "Delete") onRemove() + }}>
onRemove()}/> + onClick={onRemove} + />
- +
- - ) -} \ No newline at end of file +} diff --git a/front/components/editor/PlayerPiece.tsx b/front/components/editor/PlayerPiece.tsx index b5cc41f..69b38c2 100644 --- a/front/components/editor/PlayerPiece.tsx +++ b/front/components/editor/PlayerPiece.tsx @@ -1,11 +1,11 @@ -import React from "react"; -import '../../style/player.css' +import React from "react" +import "../../style/player.css" +import { Team } from "../../tactic/Team" - -export function PlayerPiece({team, text}: { team: string, text: string }) { +export function PlayerPiece({ team, text }: { team: Team; text: string }) { return (

{text}

) -} \ No newline at end of file +} diff --git a/front/components/editor/SavingState.tsx b/front/components/editor/SavingState.tsx new file mode 100644 index 0000000..68c2285 --- /dev/null +++ b/front/components/editor/SavingState.tsx @@ -0,0 +1,31 @@ +export interface SaveState { + className: string + message: string +} + +export class SaveStates { + static readonly Guest: SaveState = { + className: "save-state-guest", + message: "you are not connected, your changes will not be saved.", + } + static readonly Ok: SaveState = { + className: "save-state-ok", + message: "saved", + } + static readonly Saving: SaveState = { + className: "save-state-saving", + message: "saving...", + } + static readonly Err: SaveState = { + className: "save-state-error", + message: "could not save tactic.", + } +} + +export default function SavingState({ state }: { state: SaveState }) { + return ( +
+
{state.message}
+
+ ) +} diff --git a/front/style/basket_court.css b/front/style/basket_court.css index 920512b..c001cc0 100644 --- a/front/style/basket_court.css +++ b/front/style/basket_court.css @@ -1,5 +1,3 @@ - - #court-container { display: flex; @@ -12,8 +10,6 @@ -webkit-user-drag: none; } - - #court-svg * { stroke: var(--selected-team-secondarycolor); -} \ No newline at end of file +} diff --git a/front/style/colors.css b/front/style/colors.css index 54ee221..1ab3f01 100644 --- a/front/style/colors.css +++ b/front/style/colors.css @@ -1,5 +1,3 @@ - - :root { --main-color: #ffffff; --second-color: #ccde54; @@ -9,5 +7,5 @@ --selected-team-primarycolor: #50b63a; --selected-team-secondarycolor: #000000; - --selection-color: #3f7fc4 -} \ No newline at end of file + --selection-color: #3f7fc4; +} diff --git a/front/style/editor.css b/front/style/editor.css index e2d38c9..eefa561 100644 --- a/front/style/editor.css +++ b/front/style/editor.css @@ -1,6 +1,5 @@ @import "colors.css"; - #main-div { display: flex; height: 100%; @@ -10,9 +9,21 @@ flex-direction: column; } +#topbar-left { + width: 100%; + display: flex; +} + +#topbar-right { + width: 100%; + display: flex; + flex-direction: row-reverse; +} + #topbar-div { display: flex; background-color: var(--main-color); + margin-bottom: 3px; justify-content: space-between; align-items: stretch; @@ -23,15 +34,17 @@ justify-content: space-between; } -.title_input { +.title-input { width: 25ch; + align-self: center; } #edit-div { height: 100%; } -#allies-rack .player-piece , #opponent-rack .player-piece { +#allies-rack .player-piece, +#opponent-rack .player-piece { margin-left: 5px; } @@ -52,3 +65,27 @@ #court-div-bounds { width: 60%; } + +.react-draggable { + z-index: 2; +} + +.save-state { + display: flex; + align-items: center; + margin-left: 20%; + font-family: monospace; +} + +.save-state-error { + color: red; +} + +.save-state-ok { + color: green; +} + +.save-state-saving, +.save-state-guest { + color: gray; +} diff --git a/front/style/player.css b/front/style/player.css index d22a35f..166b449 100644 --- a/front/style/player.css +++ b/front/style/player.css @@ -9,15 +9,11 @@ on the court. } .player-content { - /*apply a translation to center the player piece when placed*/ - transform: translate(-29%, -46%); - display: flex; flex-direction: column; align-content: center; align-items: center; outline: none; - } .player-piece { @@ -46,14 +42,18 @@ on the court. .player-selection-tab { display: flex; + + position: absolute; margin-bottom: 10%; justify-content: center; visibility: hidden; + + width: 100%; + transform: translateY(-20px); } .player-selection-tab-remove { pointer-events: all; - width: 25%; height: 25%; } @@ -78,4 +78,4 @@ on the court. .player:focus-within { z-index: 1000; -} \ No newline at end of file +} diff --git a/front/style/title_input.css b/front/style/title_input.css index 57af59b..6d28238 100644 --- a/front/style/title_input.css +++ b/front/style/title_input.css @@ -1,4 +1,4 @@ -.title_input { +.title-input { background: transparent; border-top: none; border-right: none; @@ -9,9 +9,8 @@ border-bottom-color: transparent; } -.title_input:focus { +.title-input:focus { outline: none; border-bottom-color: blueviolet; } - diff --git a/front/style/visualizer.css b/front/style/visualizer.css new file mode 100644 index 0000000..2d1a73f --- /dev/null +++ b/front/style/visualizer.css @@ -0,0 +1,30 @@ +#main { + height: 100vh; + width: 100%; + display: flex; + flex-direction: column; +} + +#topbar { + display: flex; + background-color: var(--main-color); + justify-content: center; + align-items: center; +} + +h1 { + text-align: center; + margin-top: 0; +} + +#court-container { + flex: 1; + display: flex; + justify-content: center; + background-color: var(--main-color); +} + +#court { + max-width: 80%; + max-height: 80%; +} diff --git a/front/data/Ball.ts b/front/tactic/Ball.ts similarity index 93% rename from front/data/Ball.ts rename to front/tactic/Ball.ts index c312fdf..212823f 100644 --- a/front/data/Ball.ts +++ b/front/tactic/Ball.ts @@ -1,6 +1,5 @@ export interface Ball { - position: string, /** * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) diff --git a/front/data/Player.ts b/front/tactic/Player.ts similarity index 53% rename from front/data/Player.ts rename to front/tactic/Player.ts index 983b650..13d79fe 100644 --- a/front/data/Player.ts +++ b/front/tactic/Player.ts @@ -1,32 +1,26 @@ -export interface Player { - /** - * unique identifier of the player. - * This identifier must be unique to the associated court. - */ - id: number, +import { Team } from "./Team" +export interface Player { /** * the player's team * */ - team: "allies" | "opponents", + team: Team /** - * player's position + * player's role * */ - position: string, + role: string /** * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) */ - bottomRatio: number, - + bottomRatio: number /** * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) */ - rightRatio: number, - + rightRatio: number - hasBall: boolean, + hasBall: boolean +} -} \ No newline at end of file diff --git a/front/tactic/Tactic.ts b/front/tactic/Tactic.ts new file mode 100644 index 0000000..bb2cd37 --- /dev/null +++ b/front/tactic/Tactic.ts @@ -0,0 +1,11 @@ +import { Player } from "./Player" + +export interface Tactic { + id: number + name: string + content: TacticContent +} + +export interface TacticContent { + players: Player[] +} diff --git a/front/tactic/Team.tsx b/front/tactic/Team.tsx new file mode 100644 index 0000000..5b35943 --- /dev/null +++ b/front/tactic/Team.tsx @@ -0,0 +1,4 @@ +export enum Team { + Allies = "allies", + Opponents = "opponents", +} diff --git a/front/views/DisplayResults.tsx b/front/views/DisplayResults.tsx deleted file mode 100644 index c4bbd1b..0000000 --- a/front/views/DisplayResults.tsx +++ /dev/null @@ -1,19 +0,0 @@ - -interface DisplayResultsProps { - results: readonly { name: string, description: string}[] -} - -export default function DisplayResults({results}: DisplayResultsProps) { - const list = results - .map(({name, description}) => -
-

username: {name}

-

description: {description}

-
- ) - return ( -
- {list} -
- ) -} diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 487ee51..c3f1a29 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,48 +1,126 @@ -import {CSSProperties, ReactElement, RefObject, useRef, useState} from "react"; -import "../style/editor.css"; -import TitleInput from "../components/TitleInput"; -import {API} from "../Constants"; -import {BasketCourt} from "../components/editor/BasketCourt"; - -import {Rack} from "../components/Rack"; -import {PlayerPiece} from "../components/editor/PlayerPiece"; -import {Player} from "../data/Player"; -import {BallPiece} from "../components/editor/BallPiece"; -import {Ball} from "../data/Ball"; +import { + CSSProperties, + Dispatch, + SetStateAction, + useCallback, + useRef, + useState, +} from "react" +import "../style/editor.css" +import TitleInput from "../components/TitleInput" +import { BasketCourt } from "../components/editor/BasketCourt" + + +import { BallPiece } from "../components/editor/BallPiece"; +import { Ball } from "../tactic/Ball"; +import { Rack } from "../components/Rack" +import { PlayerPiece } from "../components/editor/PlayerPiece" +import { Player } from "../tactic/Player" +import { Tactic, TacticContent } from "../tactic/Tactic" +import { fetchAPI } from "../Fetcher" +import { Team } from "../tactic/Team" +import { calculateRatio } from "../Utils" +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" const ERROR_STYLE: CSSProperties = { - borderColor: "red" + 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 } /** * information about a player that is into a rack - */ + */ interface RackedPlayer { - team: "allies" | "opponents", - key: string, + team: Team + key: string } -export default function Editor({id, name}: { id: number, name: string }) { - const [style, setStyle] = useState({}); +export default function Editor({ + id, + name, + content, +}: { + id: number + name: string + content: string +}) { + const isInGuestMode = id == -1 - const positions = ["1", "2", "3", "4", "5"] - const positionBall = ["1"] + const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) + const editorContent = + isInGuestMode && storage_content != null ? storage_content : content + + const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) + const editorName = isInGuestMode && storage_name != null ? storage_name : name + + return ( + { + if (isInGuestMode) { + localStorage.setItem( + GUEST_MODE_CONTENT_STORAGE_KEY, + JSON.stringify(content), + ) + return SaveStates.Guest + } + return fetchAPI(`tactic/${id}/save`, { content }).then((r) => + r.ok ? SaveStates.Ok : SaveStates.Err, + ) + }} + onNameChange={async (name: string) => { + if (isInGuestMode) { + localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) + return true //simulate that the name has been changed + } + return fetchAPI(`tactic/${id}/edit/name`, { name }).then( + (r) => r.ok, + ) + }} + /> + ) +} + + +function EditorView({ + tactic: { id, name, content: initialContent }, + onContentChange, + onNameChange, +}: EditorViewProps) { + const isInGuestMode = id == -1 + + const [style, setStyle] = useState({}) + const [content, setContent, saveState] = useContentState( + initialContent, + isInGuestMode ? SaveStates.Guest : SaveStates.Ok, + onContentChange, + ) const [allies, setAllies] = useState( - positions.map(key => ({team: "allies", key})) + getRackPlayers(Team.Allies, content.players), ) const [opponents, setOpponents] = useState( - positions.map(key => ({team: "opponents", key})) + getRackPlayers(Team.Opponents, content.players), ) - const [ballPiece, setBallPiece] = useState(positionBall) + const [ball, setBall] = useState([]); - const [players, setPlayers] = useState([]); - const courtDivContentRef = useRef(null); + const courtDivContentRef = useRef(null) const canDetach = (ref: HTMLDivElement) => { - const refBounds = ref.getBoundingClientRect(); - const courtBounds = courtDivContentRef.current!.getBoundingClientRect(); + const refBounds = ref.getBoundingClientRect() + const courtBounds = courtDivContentRef.current!.getBoundingClientRect() // check if refBounds overlaps courtBounds return !( @@ -50,28 +128,29 @@ export default function Editor({id, name}: { id: number, name: string }) { refBounds.right < courtBounds.left || refBounds.bottom < courtBounds.top || refBounds.left > courtBounds.right - ); + ) } - const onElementDetach = (ref: HTMLDivElement, element: RackedPlayer) => { - const refBounds = ref.getBoundingClientRect(); - const courtBounds = courtDivContentRef.current!.getBoundingClientRect(); - const relativeXPixels = refBounds.x - courtBounds.x; - const relativeYPixels = refBounds.y - courtBounds.y; + const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { + const refBounds = ref.getBoundingClientRect() + const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const xRatio = relativeXPixels / courtBounds.width; - const yRatio = relativeYPixels / courtBounds.height; - - setPlayers(players => { - return [...players, { - id: players.length, - team: element.team, - position: element.key, - rightRatio: xRatio, - bottomRatio: yRatio, - hasBall:false - }] + const { x, y } = calculateRatio(refBounds, courtBounds) + + setContent((content) => { + return { + players: [ + ...content.players, + { + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + hasBall: false + }, + ], + } }) } @@ -83,14 +162,14 @@ export default function Editor({id, name}: { id: number, name: string }) { if (!canDetach(ref)) { return false; } - for(const player in players) { + /*for(const player in players) { const rightRatio = player - } + }*/ return false; } - const onElementDetachBall = (ref: RefObject, element: ReactElement) => { - const refBounds = ref.current!.getBoundingClientRect(); + const onElementDetachBall = (ref: HTMLDivElement) => { + const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect(); const relativeXPixels = refBounds.x - courtBounds.x; @@ -102,80 +181,102 @@ export default function Editor({id, name}: { id: number, name: string }) { setBall(ball => { return [...ball, { - position: element.props.text, right_percentage: xPercent, bottom_percentage: yPercent, }] }) } + return (
-
LEFT
- { - fetch(`${API}/tactic/${id}/edit/name`, { - method: "POST", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: new_name, - }) - }).then(response => { - if (response.ok) { - setStyle({}) - } else { - setStyle(ERROR_STYLE) - } - }) - }}/> -
RIGHT
+
+ LEFT + +
+
+ { + onNameChange(new_name).then((success) => { + setStyle(success ? {} : ERROR_STYLE) + }) + }} + /> +
+
RIGHT
- }/> - ( + + )} + /> + }/> - }/> + ( + + )} + />
{ + setContent((content) => ({ + players: toSplicedPlayers( + content.players, + player, + true, + ), + })) + }} onPlayerRemove={(player) => { - setPlayers(players => { - const idx = players.indexOf(player) - return players.toSpliced(idx, 1) - }) + setContent((content) => ({ + players: toSplicedPlayers( + content.players, + player, + false, + ), + })) + let setter switch (player.team) { - case "opponents": - setOpponents(opponents => ( - [...opponents, {team: player.team, pos: player.position, key: player.position}] - )) + case Team.Opponents: + setter = setOpponents break - case "allies": - setAllies(allies => ( - [...allies, {team: player.team, pos: player.position, key: player.position}] - )) + case Team.Allies: + setter = setAllies } - }}/> + setter((players) => [ + ...players, + { + team: player.team, + pos: player.role, + key: player.role, + }, + ]) + }} + />
@@ -183,3 +284,54 @@ export default function Editor({id, name}: { id: number, name: string }) { ) } +function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { + return ["1", "2", "3", "4", "5"] + .filter( + (role) => + players.findIndex((p) => p.team == team && p.role == role) == + -1, + ) + .map((key) => ({ team, key })) +} + +function 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] +} + +function toSplicedPlayers( + players: Player[], + player: Player, + replace: boolean, +): Player[] { + const idx = players.findIndex( + (p) => p.team === player.team && p.role === player.role, + ) + + return players.toSpliced(idx, 1, ...(replace ? [player] : [])) +} diff --git a/front/views/SampleForm.tsx b/front/views/SampleForm.tsx deleted file mode 100644 index 604e362..0000000 --- a/front/views/SampleForm.tsx +++ /dev/null @@ -1,19 +0,0 @@ - - -export default function SampleForm() { - return ( -
-

Hello, this is a sample form made in react !

-
- - - - - -
-
- ) -} - - - diff --git a/front/views/Visualizer.tsx b/front/views/Visualizer.tsx new file mode 100644 index 0000000..541da09 --- /dev/null +++ b/front/views/Visualizer.tsx @@ -0,0 +1,23 @@ +import React, { CSSProperties, useState } from "react" +import "../style/visualizer.css" +import Court from "../assets/basketball_court.svg" + +export default function Visualizer({ id, name }: { id: number; name: string }) { + const [style, setStyle] = useState({}) + + return ( +
+
+

{name}

+
+
+ Basketball Court +
+
+ ) +} diff --git a/package.json b/package.json index 97f0039..79c9d46 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "scripts": { "start": "vite --host", "build": "vite build", - "test": "vite test" + "test": "vite test", + "format": "prettier --config .prettierrc 'front' --write", + "tsc": "tsc" }, "eslintConfig": { "extends": [ @@ -30,6 +32,8 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.1.0", - "vite-plugin-svgr": "^4.1.0" + "vite-plugin-svgr": "^4.1.0", + "prettier": "^3.1.0", + "typescript": "^5.2.2" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..346baaa --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + phpVersion: 70400 + level: 6 + paths: + - src + scanFiles: + - config.php + - sql/database.php + - profiles/dev-config-profile.php + - profiles/prod-config-profile.php + excludePaths: + - src/App/react-display-file.php diff --git a/profiles/dev-config-profile.php b/profiles/dev-config-profile.php index 316ff44..bd87f1d 100644 --- a/profiles/dev-config-profile.php +++ b/profiles/dev-config-profile.php @@ -10,11 +10,7 @@ $_data_source_name = "sqlite:${_SERVER['DOCUMENT_ROOT']}/../dev-database.sqlite" const _DATABASE_USER = null; const _DATABASE_PASSWORD = null; -function _asset(string $assetURI): string -{ +function _asset(string $assetURI): string { global $front_url; return $front_url . "/" . $assetURI; } - - - diff --git a/profiles/prod-config-profile.php b/profiles/prod-config-profile.php index e185dfc..e9bb12c 100644 --- a/profiles/prod-config-profile.php +++ b/profiles/prod-config-profile.php @@ -19,4 +19,4 @@ function _asset(string $assetURI): string { // If the asset uri does not figure in the available assets array, // fallback to the uri itself. return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI); -} \ No newline at end of file +} diff --git a/public/api/index.php b/public/api/index.php index b6327e1..5734571 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -3,40 +3,58 @@ require "../../config.php"; require "../../vendor/autoload.php"; require "../../sql/database.php"; -require "../utils.php"; - -use App\Connexion; -use App\Controller\Api\APITacticController; -use App\Gateway\TacticInfoGateway; -use App\Http\JsonHttpResponse; -use App\Http\ViewHttpResponse; -use App\Model\TacticModel; - -$con = new Connexion(get_database()); +require "../../src/index-utils.php"; + +use IQBall\Api\API; +use IQBall\Api\Controller\APIAuthController; +use IQBall\Api\Controller\APITacticController; +use IQBall\App\Session\PhpSessionHandle; +use IQBall\Core\Action; +use IQBall\Core\Connection; +use IQBall\Core\Data\Account; +use IQBall\Core\Gateway\AccountGateway; +use IQBall\Core\Gateway\TacticInfoGateway; +use IQBall\Core\Model\AuthModel; +use IQBall\Core\Model\TacticModel; + +function getTacticController(): APITacticController { + return new APITacticController(new TacticModel(new TacticInfoGateway(new Connection(get_database())))); +} -$router = new AltoRouter(); -$router->setBasePath(get_public_path() . "/api"); +function getAuthController(): APIAuthController { + return new APIAuthController(new AuthModel(new AccountGateway(new Connection(get_database())))); +} -$tacticEndpoint = new APITacticController(new TacticModel(new TacticInfoGateway($con))); -$router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->updateName($id)); -$router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->getTacticInfo($id)); -$router->map("POST", "/tactic/new", fn() => $tacticEndpoint->newTactic()); +function getRoutes(): AltoRouter { + $router = new AltoRouter(); + $router->setBasePath(get_public_path(__DIR__)); -$match = $router->match(); + $router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize())); + $router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); + $router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc))); -if ($match == null) { - echo "404 not found"; - header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); - exit(1); + return $router; } -$response = call_user_func_array($match['target'], $match['params']); - -http_response_code($response->getCode()); +/** + * Defines the way of being authorised through the API + * By checking if an Authorisation header is set, and by expecting its value to be a valid token of an account. + * If the header is not set, fallback to the App's PHP session system, and try to extract the account from it. + * @return Account|null + * @throws Exception + */ +function tryGetAuthorization(): ?Account { + $headers = getallheaders(); + + // If no authorization header is set, try fallback to php session. + if (!isset($headers['Authorization'])) { + $session = PhpSessionHandle::init(); + return $session->getAccount(); + } + + $token = $headers['Authorization']; + $gateway = new AccountGateway(new Connection(get_database())); + return $gateway->getAccountFromToken($token); +} -if ($response instanceof JsonHttpResponse) { - header('Content-type: application/json'); - echo $response->getJson(); -} else if ($response instanceof ViewHttpResponse) { - throw new Exception("API returned a view http response."); -} \ No newline at end of file +Api::render(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization())); diff --git a/public/assets b/public/assets new file mode 120000 index 0000000..7b299d9 --- /dev/null +++ b/public/assets @@ -0,0 +1 @@ +../front/assets \ No newline at end of file diff --git a/public/index.php b/public/index.php index ba9d7c0..78ee4d6 100644 --- a/public/index.php +++ b/public/index.php @@ -1,76 +1,124 @@ setBasePath($basePath); +function getEditorController(): EditorController { + return new EditorController(new TacticModel(new TacticInfoGateway(getConnection()))); +} -$sampleFormController = new SampleFormController(new FormResultGateway($con), $twig); -$editorController = new EditorController(new TacticModel(new TacticInfoGateway($con))); +function getTeamController(): TeamController { + $con = getConnection(); + return new TeamController(new TeamModel(new TeamGateway($con), new MemberGateway($con), new AccountGateway($con))); +} +function getAuthController(): AuthController { + return new AuthController(new AuthModel(new AccountGateway(getConnection()))); +} -$router->map("GET", "/", fn() => $sampleFormController->displayFormReact()); -$router->map("POST", "/submit", fn() => $sampleFormController->submitFormReact($_POST)); -$router->map("GET", "/twig", fn() => $sampleFormController->displayFormTwig()); -$router->map("POST", "/submit-twig", fn() => $sampleFormController->submitFormTwig($_POST)); -$router->map("GET", "/tactic/new", fn() => $editorController->makeNew()); -$router->map("GET", "/tactic/[i:id]/edit", fn(int $id) => $editorController->openEditorFor($id)); +function getTwig(): Environment { + global $basePath; + $fl = new FilesystemLoader("../src/App/Views"); + $twig = new Environment($fl); -$match = $router->match(); + $twig->addFunction(new TwigFunction('path', fn(string $str) => "$basePath$str")); -if ($match == null) { - http_response_code(404); - ErrorController::displayFailures([ValidationFail::notFound("Cette page n'existe pas")], $twig); - return; + return $twig; } -$response = call_user_func_array($match['target'], $match['params']); - -http_response_code($response->getCode()); - -if ($response instanceof ViewHttpResponse) { - $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->display($file, $args); - } catch (\Twig\Error\RuntimeError|\Twig\Error\SyntaxError $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; +function getRoutes(): AltoRouter { + global $basePath; + + $ar = new AltoRouter(); + $ar->setBasePath($basePath); + + //authentication + $ar->map("GET", "/login", Action::noAuth(fn() => getAuthController()->displayLogin())); + $ar->map("GET", "/register", Action::noAuth(fn() => getAuthController()->displayRegister())); + $ar->map("POST", "/login", Action::noAuth(fn(SessionHandle $s) => getAuthController()->login($_POST, $s))); + $ar->map("POST", "/register", Action::noAuth(fn(SessionHandle $s) => getAuthController()->register($_POST, $s))); + + //user-related + $ar->map("GET", "/", Action::auth(fn(SessionHandle $s) => getUserController()->home($s))); + $ar->map("GET", "/home", Action::auth(fn(SessionHandle $s) => getUserController()->home($s))); + $ar->map("GET", "/settings", Action::auth(fn(SessionHandle $s) => getUserController()->settings($s))); + + //tactic-related + $ar->map("GET", "/tactic/[i:id]/view", Action::auth(fn(int $id, SessionHandle $s) => getVisualizerController()->openVisualizer($id, $s))); + $ar->map("GET", "/tactic/[i:id]/edit", Action::auth(fn(int $id, SessionHandle $s) => getEditorController()->openEditor($id, $s))); + // don't require an authentication to run this action. + // If the user is not connected, the tactic will never save. + $ar->map("GET", "/tactic/new", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNew($s))); + + //team-related + $ar->map("GET", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->displayCreateTeam($s))); + $ar->map("POST", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->submitTeam($_POST, $s))); + $ar->map("GET", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->displayListTeamByName($s))); + $ar->map("POST", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->listTeamByName($_POST, $s))); + $ar->map("GET", "/team/[i:id]", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->displayTeam($id, $s))); + $ar->map("GET", "/team/members/add", Action::auth(fn(SessionHandle $s) => getTeamController()->displayAddMember($s))); + $ar->map("POST", "/team/members/add", Action::auth(fn(SessionHandle $s) => getTeamController()->addMember($_POST, $s))); + $ar->map("GET", "/team/members/remove", Action::auth(fn(SessionHandle $s) => getTeamController()->displayDeleteMember($s))); + $ar->map("POST", "/team/members/remove", Action::auth(fn(SessionHandle $s) => getTeamController()->deleteMember($_POST, $s))); + + return $ar; +} + +function runMatch($match, MutableSessionHandle $session): HttpResponse { + global $basePath; + if (!$match) { + return ViewHttpResponse::twig("error.html.twig", [ + 'failures' => [ValidationFail::notFound("Could not find page {$_SERVER['REQUEST_URI']}.")], + ], HttpCodes::NOT_FOUND); } -} else if ($response instanceof JsonHttpResponse) { - header('Content-type: application/json'); - echo $response->getJson(); -} \ No newline at end of file + return App::runAction($basePath . '/login', $match['target'], $match['params'], $session); +} + + +//this is a global variable +$basePath = get_public_path(__DIR__); + +App::render(runMatch(getRoutes()->match(), PhpSessionHandle::init()), fn() => getTwig()); diff --git a/sql/database.php b/sql/database.php index d49ddfd..8f5aa9d 100644 --- a/sql/database.php +++ b/sql/database.php @@ -25,6 +25,3 @@ function get_database(): PDO { return $pdo; } - - - diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 068c2e1..eb74877 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -1,12 +1,50 @@ - -- drop tables here -DROP TABLE IF EXISTS FormEntries; -DROP TABLE IF EXISTS TacticInfo; +DROP TABLE IF EXISTS Account; +DROP TABLE IF EXISTS Tactic; +DROP TABLE IF EXISTS Team; +DROP TABLE IF EXISTS User; +DROP TABLE IF EXISTS Member; +CREATE TABLE Account +( + id integer PRIMARY KEY AUTOINCREMENT, + email varchar UNIQUE NOT NULL, + username varchar NOT NULL, + token varchar UNIQUE NOT NULL, + hash varchar NOT NULL +); + +CREATE TABLE Tactic +( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar NOT NULL, + creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + owner integer NOT NULL, + content varchar DEFAULT '{"players": []}' NOT NULL, + FOREIGN KEY (owner) REFERENCES Account +); + +CREATE TABLE FormEntries +( + name varchar, + description varchar +); + + +CREATE TABLE Team +( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar, + picture varchar, + main_color varchar, + second_color varchar +); -CREATE TABLE FormEntries(name varchar, description varchar); -CREATE TABLE TacticInfo( - id integer PRIMARY KEY AUTOINCREMENT, - name varchar, - creation_date timestamp DEFAULT CURRENT_TIMESTAMP -); \ No newline at end of file +CREATE TABLE Member +( + id_team integer, + id_user integer, + role text CHECK (role IN ('Coach', 'Player')), + FOREIGN KEY (id_team) REFERENCES Team (id), + FOREIGN KEY (id_user) REFERENCES User (id) +); diff --git a/src/Api/API.php b/src/Api/API.php new file mode 100644 index 0000000..da00749 --- /dev/null +++ b/src/Api/API.php @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000..fc0eef6 --- /dev/null +++ b/src/Api/Controller/APIAuthController.php @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..79e766c --- /dev/null +++ b/src/Api/Controller/APITacticController.php @@ -0,0 +1,65 @@ +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->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/App.php b/src/App/App.php new file mode 100644 index 0000000..cd3c293 --- /dev/null +++ b/src/App/App.php @@ -0,0 +1,92 @@ +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/Controller/Control.php b/src/App/Control.php similarity index 54% rename from src/Controller/Control.php rename to src/App/Control.php index 8c00d2e..5c2fe0f 100644 --- a/src/Controller/Control.php +++ b/src/App/Control.php @@ -1,61 +1,48 @@ Validators` which represents the request object schema - * @param callable $run the callback to run if the request is valid according to the given schema. + * @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 runChecked(array $schema, callable $run, bool $errorInJson): 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"); - if($errorInJson) { - return new JsonHttpResponse([$fail, HttpCodes::BAD_REQUEST]); - } return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST); } $payload = get_object_vars($payload_obj); - return self::runCheckedFrom($payload, $schema, $run, $errorInJson); + 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 $run the callback to run if the request is valid according to 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, bool $errorInJson): HttpResponse { + public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { $fails = []; $request = HttpRequest::from($data, $fails, $schema); if (!empty($fails)) { - if($errorInJson) { - return new JsonHttpResponse($fails, HttpCodes::BAD_REQUEST); - } return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST); } return call_user_func_array($run, [$request]); } - - - - - -} \ No newline at end of file +} diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php new file mode 100644 index 0000000..dfc5f52 --- /dev/null +++ b/src/App/Controller/AuthController.php @@ -0,0 +1,87 @@ +model = $model; + } + + public function displayRegister(): HttpResponse { + return ViewHttpResponse::twig("display_register.html.twig", []); + } + + /** + * registers given account + * @param mixed[] $request + * @param MutableSessionHandle $session + * @return HttpResponse + */ + public function register(array $request, MutableSessionHandle $session): HttpResponse { + $fails = []; + $request = HttpRequest::from($request, $fails, [ + "username" => [Validators::name(), Validators::lenBetween(2, 32)], + "password" => [Validators::lenBetween(6, 256)], + "confirmpassword" => [Validators::lenBetween(6, 256)], + "email" => [Validators::email(), Validators::lenBetween(5, 256)], + ]); + if (!empty($fails)) { + return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]); + } + $account = $this->model->register($request['username'], $request["password"], $request['confirmpassword'], $request['email'], $fails); + if (!empty($fails)) { + return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]); + } + + $session->setAccount($account); + + $target_url = $session->getInitialTarget(); + return HttpResponse::redirect($target_url ?? "/home"); + } + + + public function displayLogin(): HttpResponse { + return ViewHttpResponse::twig("display_login.html.twig", []); + } + + /** + * logins given account credentials + * @param mixed[] $request + * @param MutableSessionHandle $session + * @return HttpResponse + */ + public function login(array $request, MutableSessionHandle $session): HttpResponse { + $fails = []; + $request = HttpRequest::from($request, $fails, [ + "password" => [Validators::lenBetween(6, 256)], + "email" => [Validators::email(), Validators::lenBetween(5, 256)], + ]); + if (!empty($fails)) { + return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails]); + } + + $account = $this->model->login($request['email'], $request['password'], $fails); + if (!empty($fails)) { + return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails]); + } + + $session->setAccount($account); + + $target_url = $session->getInitialTarget(); + $session->setInitialTarget(null); + return HttpResponse::redirect($target_url ?? "/home"); + } + +} diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php new file mode 100644 index 0000000..3994093 --- /dev/null +++ b/src/App/Controller/EditorController.php @@ -0,0 +1,76 @@ +model = $model; + } + + /** + * @param TacticInfo $tactic + * @return ViewHttpResponse the editor view for given tactic + */ + private function openEditorFor(TacticInfo $tactic): ViewHttpResponse { + return ViewHttpResponse::react("views/Editor.tsx", [ + "id" => $tactic->getId(), + "name" => $tactic->getName(), + "content" => $tactic->getContent(), + ]); + } + + /** + * @return ViewHttpResponse the editor view for a test tactic. + */ + private function openTestEditor(): ViewHttpResponse { + return ViewHttpResponse::react("views/Editor.tsx", [ + "id" => -1, //-1 id means that the editor will not support saves + "content" => '{"players": []}', + "name" => TacticModel::TACTIC_DEFAULT_NAME, + ]); + } + + /** + * creates a new empty tactic, with default name + * If the given session does not contain a connected account, + * open a test editor. + * @param SessionHandle $session + * @return ViewHttpResponse the editor view + */ + public function createNew(SessionHandle $session): ViewHttpResponse { + $account = $session->getAccount(); + + if ($account == null) { + return $this->openTestEditor(); + } + $tactic = $this->model->makeNewDefault($account->getId()); + return $this->openEditorFor($tactic); + } + + /** + * returns an editor view for a given tactic + * @param int $id the targeted tactic identifier + * @param SessionHandle $session + * @return ViewHttpResponse + */ + public function openEditor(int $id, SessionHandle $session): ViewHttpResponse { + $tactic = $this->model->get($id); + + $failure = TacticValidator::validateAccess($id, $tactic, $session->getAccount()->getId()); + + if ($failure != null) { + return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND); + } + + return $this->openEditorFor($tactic); + } +} diff --git a/src/App/Controller/TeamController.php b/src/App/Controller/TeamController.php new file mode 100644 index 0000000..b2c0ea9 --- /dev/null +++ b/src/App/Controller/TeamController.php @@ -0,0 +1,155 @@ +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 add a member + */ + public function displayAddMember(SessionHandle $session): ViewHttpResponse { + return ViewHttpResponse::twig("add_member.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']); + return $this->displayTeam($teamId, $session); + } + + /** + * @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']); + + if (empty($teams)) { + return ViewHttpResponse::twig('display_teams.html.twig', []); + } + + return ViewHttpResponse::twig('display_teams.html.twig', ['teams' => $teams]); + } + + /** + * @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); + return ViewHttpResponse::twig('display_team.html.twig', ['team' => $result]); + } + + /** + * add a member to a team + * @param array $request + * @param SessionHandle $session + * @return HttpResponse + */ + public function addMember(array $request, SessionHandle $session): HttpResponse { + $errors = []; + + $request = HttpRequest::from($request, $errors, [ + "team" => [Validators::isInteger()], + "email" => [Validators::email(), Validators::lenBetween(5, 256)], + ]); + + $teamId = intval($request['team']); + $this->model->addMember($request['email'], $teamId, $request['role']); + return $this->displayTeam($teamId, $session); + } + + /** + * remove a member from a team + * @param array $request + * @param SessionHandle $session + * @return HttpResponse + */ + public function deleteMember(array $request, SessionHandle $session): HttpResponse { + $errors = []; + + $request = HttpRequest::from($request, $errors, [ + "team" => [Validators::isInteger()], + "email" => [Validators::email(), Validators::lenBetween(5, 256)], + ]); + + return $this->displayTeam($this->model->deleteMember($request['email'], intval($request['team'])), $session); + } +} diff --git a/src/App/Controller/UserController.php b/src/App/Controller/UserController.php new file mode 100644 index 0000000..d6f9f89 --- /dev/null +++ b/src/App/Controller/UserController.php @@ -0,0 +1,36 @@ +tactics = $tactics; + } + + /** + * @param SessionHandle $session + * @return ViewHttpResponse the home page view + */ + public function home(SessionHandle $session): ViewHttpResponse { + //TODO use session's account to get the last 5 tactics of the logged-in account + $listTactic = $this->tactics->getLast(5); + return ViewHttpResponse::twig("home.twig", ["recentTactic" => $listTactic]); + } + + /** + * @return ViewHttpResponse account settings page + */ + public function settings(SessionHandle $session): ViewHttpResponse { + return ViewHttpResponse::twig("account_settings.twig", []); + } + +} diff --git a/src/App/Controller/VisualizerController.php b/src/App/Controller/VisualizerController.php new file mode 100644 index 0000000..631468e --- /dev/null +++ b/src/App/Controller/VisualizerController.php @@ -0,0 +1,39 @@ +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()->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 new file mode 100644 index 0000000..9ef23c0 --- /dev/null +++ b/src/App/Session/MutableSessionHandle.php @@ -0,0 +1,20 @@ +getOwnerId() != $ownerId) { + return ValidationFail::unauthorized("Vous ne pouvez pas accéder à cette tactique."); + } + return null; + } + +} diff --git a/src/Http/ViewHttpResponse.php b/src/App/ViewHttpResponse.php similarity index 81% rename from src/Http/ViewHttpResponse.php rename to src/App/ViewHttpResponse.php index 0e92054..dfbd1da 100644 --- a/src/Http/ViewHttpResponse.php +++ b/src/App/ViewHttpResponse.php @@ -1,9 +1,11 @@ View arguments */ private array $arguments; /** @@ -24,10 +26,10 @@ class ViewHttpResponse extends HttpResponse { * @param int $code * @param int $kind * @param string $file - * @param array $arguments + * @param array $arguments */ private function __construct(int $kind, string $file, array $arguments, int $code = HttpCodes::OK) { - parent::__construct($code); + parent::__construct($code, []); $this->kind = $kind; $this->file = $file; $this->arguments = $arguments; @@ -41,6 +43,9 @@ class ViewHttpResponse extends HttpResponse { return $this->file; } + /** + * @return array + */ public function getArguments(): array { return $this->arguments; } @@ -48,7 +53,7 @@ class ViewHttpResponse extends HttpResponse { /** * Create a twig view response * @param string $file - * @param array $arguments + * @param array $arguments * @param int $code * @return ViewHttpResponse */ @@ -59,7 +64,7 @@ class ViewHttpResponse extends HttpResponse { /** * Create a react view response * @param string $file - * @param array $arguments + * @param array $arguments * @param int $code * @return ViewHttpResponse */ @@ -67,4 +72,4 @@ class ViewHttpResponse extends HttpResponse { return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code); } -} \ No newline at end of file +} diff --git a/src/App/Views/account_settings.twig b/src/App/Views/account_settings.twig new file mode 100644 index 0000000..04d7437 --- /dev/null +++ b/src/App/Views/account_settings.twig @@ -0,0 +1,23 @@ + + + + + + + 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 new file mode 100644 index 0000000..c6bae0e --- /dev/null +++ b/src/App/Views/add_member.html.twig @@ -0,0 +1,103 @@ + + + + + Ajouter un membre + + + + +
+

Ajouter un membre à votre équipe

+
+
+ + + + + +
+ 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 new file mode 100644 index 0000000..3fa5ccd --- /dev/null +++ b/src/App/Views/delete_member.html.twig @@ -0,0 +1,73 @@ + + + + + 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 new file mode 100644 index 0000000..60c63b2 --- /dev/null +++ b/src/App/Views/display_auth_confirm.html.twig @@ -0,0 +1,46 @@ + + + + + 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 new file mode 100644 index 0000000..cdc11a5 --- /dev/null +++ b/src/App/Views/display_login.html.twig @@ -0,0 +1,96 @@ + + + + Connexion + + + + + +
+

Se connecter

+
+
+ + {% for name in fails %} + + {% endfor %} + + + + + + +
+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/src/App/Views/display_register.html.twig b/src/App/Views/display_register.html.twig new file mode 100644 index 0000000..8649de8 --- /dev/null +++ b/src/App/Views/display_register.html.twig @@ -0,0 +1,102 @@ + + + + S'enregistrer + + + + + +
+

S'enregistrer

+
+
+ + {% for name in fails %} + + {% endfor %} + + + + + + + + + + +
+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/src/Views/display_results.html.twig b/src/App/Views/display_results.html.twig similarity index 91% rename from src/Views/display_results.html.twig rename to src/App/Views/display_results.html.twig index 6d2aef0..a33546b 100644 --- a/src/Views/display_results.html.twig +++ b/src/App/Views/display_results.html.twig @@ -1,4 +1,3 @@ - @@ -14,5 +13,6 @@

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 new file mode 100644 index 0000000..7f23b8b --- /dev/null +++ b/src/App/Views/display_team.html.twig @@ -0,0 +1,91 @@ + + + + + Twig view + + + +
+

IQBall

+
+ +
+ +
+
+

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

+ +
+
+

Couleur principale :

+
+
+

Couleur secondaire :

+
+
+
+ + {% for m in team.listMembers() %} +

{{ m.getUserId() }}

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

: Coach

+ {% else %} +

: Joueur

+ {% endif %} + {% endfor %} +
+ +
+ + \ No newline at end of file diff --git a/src/App/Views/display_teams.html.twig b/src/App/Views/display_teams.html.twig new file mode 100644 index 0000000..1e1420a --- /dev/null +++ b/src/App/Views/display_teams.html.twig @@ -0,0 +1,33 @@ + + + + + Twig view + + + +{% if teams is empty %} +

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

+
+

Chercher une équipe

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

Nom de l'équipe : {{ t.name }}

+ logo de l'équipe +
+ {% endfor %} +{% endif %} + + + \ No newline at end of file diff --git a/src/App/Views/error.html.twig b/src/App/Views/error.html.twig new file mode 100644 index 0000000..bf90319 --- /dev/null +++ b/src/App/Views/error.html.twig @@ -0,0 +1,57 @@ + + + + + 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 new file mode 100644 index 0000000..5d9e8ae --- /dev/null +++ b/src/App/Views/home.twig @@ -0,0 +1,96 @@ + + + + + + + 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éé !

+{% endif %} + +

Mes strategies

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

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

+ +
+ {% endfor %} +{% else %} +

Aucune tactique créé !

+{% 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 new file mode 100644 index 0000000..65cd096 --- /dev/null +++ b/src/App/Views/insert_team.html.twig @@ -0,0 +1,82 @@ + + + + + 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 new file mode 100644 index 0000000..eca5e19 --- /dev/null +++ b/src/App/Views/list_team_by_name.html.twig @@ -0,0 +1,74 @@ + + + + + Insertion view + + + + +
+

Chercher une équipe

+
+
+ + +
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/src/react-display-file.php b/src/App/react-display-file.php similarity index 100% rename from src/react-display-file.php rename to src/App/react-display-file.php diff --git a/src/react-display.php b/src/App/react-display.php similarity index 61% rename from src/react-display.php rename to src/App/react-display.php index b965a3a..5baf41b 100644 --- a/src/react-display.php +++ b/src/App/react-display.php @@ -3,11 +3,11 @@ /** * sends a react view to the user client. * @param string $url url of the react file to render - * @param array $arguments arguments to pass to the rendered react component - * The arguments must be a json-encodable key/value dictionary. + * @param array $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"; -} \ No newline at end of file +} diff --git a/src/Connexion.php b/src/Connexion.php deleted file mode 100644 index 788c0fb..0000000 --- a/src/Connexion.php +++ /dev/null @@ -1,53 +0,0 @@ -pdo = $pdo; - } - - public function lastInsertId() { - return $this->pdo->lastInsertId(); - } - - /** - * execute a request - * @param string $query - * @param array $args - * @return void - */ - public function exec(string $query, array $args) { - $stmnt = $this->prepare($query, $args); - $stmnt->execute(); - } - - /** - * Execute a request, and return the returned rows - * @param string $query the SQL request - * @param array $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]` - * @return array the returned rows of the request - */ - public function fetch(string $query, array $args): array { - $stmnt = $this->prepare($query, $args); - $stmnt->execute(); - return $stmnt->fetchAll(PDO::FETCH_ASSOC); - } - - private function prepare(string $query, array $args): \PDOStatement { - $stmnt = $this->pdo->prepare($query); - foreach ($args as $name => $value) { - $stmnt->bindValue($name, $value[0], $value[1]); - } - return $stmnt; - } - -} \ No newline at end of file diff --git a/src/Controller/Api/APITacticController.php b/src/Controller/Api/APITacticController.php deleted file mode 100644 index a39b2ce..0000000 --- a/src/Controller/Api/APITacticController.php +++ /dev/null @@ -1,55 +0,0 @@ -model = $model; - } - - public function updateName(int $tactic_id): HttpResponse { - return Control::runChecked([ - "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()] - ], function (HttpRequest $request) use ($tactic_id) { - $this->model->updateName($tactic_id, $request["name"]); - return HttpResponse::fromCode(HttpCodes::OK); - }, true); - } - - public function newTactic(): HttpResponse { - return Control::runChecked([ - "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()] - ], function (HttpRequest $request) { - $tactic = $this->model->makeNew($request["name"]); - $id = $tactic->getId(); - return new JsonHttpResponse(["id" => $id]); - }, true); - } - - public function getTacticInfo(int $id): HttpResponse { - $tactic_info = $this->model->get($id); - - if ($tactic_info == null) { - return new JsonHttpResponse("could not find tactic #$id", HttpCodes::NOT_FOUND); - } - - return new JsonHttpResponse($tactic_info); - } - -} \ No newline at end of file diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php deleted file mode 100644 index bf5dccc..0000000 --- a/src/Controller/EditorController.php +++ /dev/null @@ -1,48 +0,0 @@ -model = $model; - } - - private function openEditor(TacticInfo $tactic): HttpResponse { - return ViewHttpResponse::react("views/Editor.tsx", ["name" => $tactic->getName(), "id" => $tactic->getId()]); - } - - public function makeNew(): HttpResponse { - $tactic = $this->model->makeNewDefault(); - return $this->openEditor($tactic); - } - - /** - * returns an editor view for a given tactic - * @param int $id the targeted tactic identifier - * @return HttpResponse - */ - public function openEditorFor(int $id): HttpResponse { - $tactic = $this->model->get($id); - - if ($tactic == null) { - return new JsonHttpResponse("la tactique " . $id . " n'existe pas", HttpCodes::NOT_FOUND); - } - - return $this->openEditor($tactic); - } - -} \ No newline at end of file diff --git a/src/Controller/ErrorController.php b/src/Controller/ErrorController.php deleted file mode 100644 index e91d05f..0000000 --- a/src/Controller/ErrorController.php +++ /dev/null @@ -1,20 +0,0 @@ -display("error.html.twig", ['failures' => $failures]); - } catch (LoaderError | RuntimeError | SyntaxError $e) { - echo "Twig error: $e"; - } - } -} diff --git a/src/Controller/SampleFormController.php b/src/Controller/SampleFormController.php deleted file mode 100644 index 4241ad4..0000000 --- a/src/Controller/SampleFormController.php +++ /dev/null @@ -1,52 +0,0 @@ -gateway = $gateway; - } - - - public function displayFormReact(): HttpResponse { - return ViewHttpResponse::react("views/SampleForm.tsx", []); - } - - public function displayFormTwig(): HttpResponse { - return ViewHttpResponse::twig('sample_form.html.twig', []); - } - - private function submitForm(array $form, callable $response): HttpResponse { - return Control::runCheckedFrom($form, [ - "name" => [Validators::lenBetween(0, 32), Validators::name("Le nom ne peut contenir que des lettres, des chiffres et des accents")], - "description" => [Validators::lenBetween(0, 512)] - ], function (HttpRequest $req) use ($response) { - $description = htmlspecialchars($req["description"]); - $this->gateway->insert($req["name"], $description); - $results = ["results" => $this->gateway->listResults()]; - return call_user_func_array($response, [$results]); - }, false); - } - - public function submitFormTwig(array $form): HttpResponse { - return $this->submitForm($form, fn(array $results) => ViewHttpResponse::twig('display_results.html.twig', $results)); - } - - public function submitFormReact(array $form): HttpResponse { - return $this->submitForm($form, fn(array $results) => ViewHttpResponse::react('views/DisplayResults.tsx', $results)); - } -} \ No newline at end of file diff --git a/src/Core/Action.php b/src/Core/Action.php new file mode 100644 index 0000000..35721c1 --- /dev/null +++ b/src/Core/Action.php @@ -0,0 +1,58 @@ +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/Connection.php b/src/Core/Connection.php new file mode 100644 index 0000000..1dd559d --- /dev/null +++ b/src/Core/Connection.php @@ -0,0 +1,61 @@ +pdo = $pdo; + } + + public function lastInsertId(): string { + return $this->pdo->lastInsertId(); + } + + /** + * execute a request + * @param string $query + * @param array> $args + * @return void + */ + public function exec(string $query, array $args) { + $stmnt = $this->prep($query, $args); + $stmnt->execute(); + } + + /** + * Execute a request, and return the returned rows + * @param string $query the SQL request + * @param array> $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]` + * @return array[] the returned rows of the request + */ + public function fetch(string $query, array $args): array { + $stmnt = $this->prep($query, $args); + $stmnt->execute(); + return $stmnt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @param string $query + * @param array> $args + * @return \PDOStatement + */ + private function prep(string $query, array $args): \PDOStatement { + $stmnt = $this->pdo->prepare($query); + foreach ($args as $name => $value) { + $stmnt->bindValue($name, $value[0], $value[1]); + } + return $stmnt; + } + + public function prepare(string $query): \PDOStatement { + return $this->pdo->prepare($query); + } + +} diff --git a/src/Core/Data/Account.php b/src/Core/Data/Account.php new file mode 100755 index 0000000..48b3e69 --- /dev/null +++ b/src/Core/Data/Account.php @@ -0,0 +1,61 @@ +email = $email; + $this->name = $name; + $this->token = $token; + $this->id = $id; + } + + public function getId(): int { + return $this->id; + } + + public function getEmail(): string { + return $this->email; + } + + public function getToken(): string { + return $this->token; + } + + public function getName(): string { + return $this->name; + } + +} diff --git a/src/Core/Data/Color.php b/src/Core/Data/Color.php new file mode 100755 index 0000000..e0cd27c --- /dev/null +++ b/src/Core/Data/Color.php @@ -0,0 +1,44 @@ +hex = $value; + } + + /** + * @return string + */ + public function getValue(): string { + return $this->hex; + } + + public static function from(string $value): Color { + $color = self::tryFrom($value); + if ($color == null) { + var_dump($value); + throw new InvalidArgumentException("The string is not an hexadecimal code"); + } + return $color; + } + + public static function tryFrom(string $value): ?Color { + if (!preg_match('/#(?:[0-9a-fA-F]{6})/', $value)) { + return null; + } + return new Color($value); + } +} diff --git a/src/Data/Member.php b/src/Core/Data/Member.php similarity index 60% rename from src/Data/Member.php rename to src/Core/Data/Member.php index 91b09c4..d68140c 100755 --- a/src/Data/Member.php +++ b/src/Core/Data/Member.php @@ -1,16 +1,21 @@ userId = $userId; + $this->teamId = $teamId; $this->role = $role; } + /** * @return int */ @@ -39,4 +46,10 @@ class Member { return $this->role; } -} \ No newline at end of file + /** + * @return int + */ + public function getTeamId(): int { + return $this->teamId; + } +} diff --git a/src/Core/Data/MemberRole.php b/src/Core/Data/MemberRole.php new file mode 100755 index 0000000..41b6b71 --- /dev/null +++ b/src/Core/Data/MemberRole.php @@ -0,0 +1,68 @@ +isValid($val)) { + throw new InvalidArgumentException("Valeur du rôle invalide"); + } + $this->value = $val; + } + + public static function player(): MemberRole { + return new MemberRole(MemberRole::ROLE_PLAYER); + } + + public static function coach(): MemberRole { + return new MemberRole(MemberRole::ROLE_COACH); + } + + public function name(): string { + switch ($this->value) { + case self::ROLE_COACH: + return "Coach"; + case self::ROLE_PLAYER: + return "Player"; + } + die("unreachable"); + } + + public static function fromName(string $name): ?MemberRole { + switch ($name) { + case "Coach": + return MemberRole::coach(); + case "Player": + return MemberRole::player(); + default: + return null; + } + } + + private function isValid(int $val): bool { + return ($val <= self::MAX and $val >= self::MIN); + } + + public function isPlayer(): bool { + return ($this->value == self::ROLE_PLAYER); + } + + public function isCoach(): bool { + return ($this->value == self::ROLE_COACH); + } + +} diff --git a/src/Core/Data/TacticInfo.php b/src/Core/Data/TacticInfo.php new file mode 100644 index 0000000..2565f93 --- /dev/null +++ b/src/Core/Data/TacticInfo.php @@ -0,0 +1,56 @@ +id = $id; + $this->name = $name; + $this->ownerId = $ownerId; + $this->creationDate = $creationDate; + $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; + } + + /** + * @return int + */ + public function getCreationDate(): int { + return $this->creationDate; + } +} diff --git a/src/Core/Data/Team.php b/src/Core/Data/Team.php new file mode 100755 index 0000000..b8e7834 --- /dev/null +++ b/src/Core/Data/Team.php @@ -0,0 +1,32 @@ +info = $info; + $this->members = $members; + } + + public function getInfo(): TeamInfo { + return $this->info; + } + + /** + * @return Member[] + */ + public function listMembers(): array { + return $this->members; + } +} diff --git a/src/Core/Data/TeamInfo.php b/src/Core/Data/TeamInfo.php new file mode 100644 index 0000000..7affcea --- /dev/null +++ b/src/Core/Data/TeamInfo.php @@ -0,0 +1,49 @@ +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(): Color { + return $this->mainColor; + } + + public function getSecondColor(): Color { + return $this->secondColor; + } + + +} diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php new file mode 100644 index 0000000..7740b57 --- /dev/null +++ b/src/Core/Gateway/AccountGateway.php @@ -0,0 +1,85 @@ +con = $con; + } + + + public function insertAccount(string $name, string $email, string $token, string $hash): int { + $this->con->exec("INSERT INTO Account(username, hash, email, token) VALUES (:username,:hash,:email,:token)", [ + ':username' => [$name, PDO::PARAM_STR], + ':hash' => [$hash, PDO::PARAM_STR], + ':email' => [$email, PDO::PARAM_STR], + ':token' => [$token, PDO::PARAM_STR], + ]); + return intval($this->con->lastInsertId()); + } + + /** + * @param string $email + * @return array|null + */ + private function getRowsFromMail(string $email): ?array { + return $this->con->fetch("SELECT * FROM Account WHERE email = :email", [':email' => [$email, PDO::PARAM_STR]])[0] ?? null; + } + + /** + * @param string $email + * @return string|null the hashed user's password, or null if the given mail does not exist + */ + public function getHash(string $email): ?string { + $results = $this->getRowsFromMail($email); + if ($results == null) { + return null; + } + return $results['hash']; + } + + /** + * @param string $email + * @return bool true if the given email exists in the database + */ + public function exists(string $email): bool { + return $this->getRowsFromMail($email) != null; + } + + /** + * @param string $email + * @return Account|null + */ + public function getAccountFromMail(string $email): ?Account { + $acc = $this->getRowsFromMail($email); + if (empty($acc)) { + return null; + } + + return new Account($email, $acc["username"], $acc["token"], $acc["id"]); + } + + /** + * @param string $token get an account from given token + * @return Account|null + */ + public function getAccountFromToken(string $token): ?Account { + $acc = $this->con->fetch("SELECT * FROM Account WHERE token = :token", [':token' => [$token, PDO::PARAM_STR]])[0] ?? null; + if (empty($acc)) { + return null; + } + + return new Account($acc["email"], $acc["username"], $acc["token"], $acc["id"]); + } + + +} diff --git a/src/Core/Gateway/MemberGateway.php b/src/Core/Gateway/MemberGateway.php new file mode 100644 index 0000000..999bf10 --- /dev/null +++ b/src/Core/Gateway/MemberGateway.php @@ -0,0 +1,69 @@ +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,m.role,a.email,a.username 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($row['id_user'], $row['id_team'], MemberRole::fromName($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], + ] + ); + } + +} diff --git a/src/Core/Gateway/TacticInfoGateway.php b/src/Core/Gateway/TacticInfoGateway.php new file mode 100644 index 0000000..447c7a5 --- /dev/null +++ b/src/Core/Gateway/TacticInfoGateway.php @@ -0,0 +1,102 @@ +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]; + + return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"], $row['content']); + } + + + /** + * Return the nb last tactics created + * + * @param integer $nb + * @return array> + */ + public function getLast(int $nb): ?array { + $res = $this->con->fetch( + "SELECT * FROM Tactic ORDER BY creation_date DESC LIMIT :nb ", + [":nb" => [$nb, PDO::PARAM_INT]] + ); + if (count($res) == 0) { + return null; + } + return $res; + } + + /** + * @param string $name + * @param int $owner + * @return int inserted tactic id + */ + public function insert(string $name, int $owner): int { + $this->con->exec( + "INSERT INTO Tactic(name, owner) VALUES(:name, :owner)", + [ + ":name" => [$name, PDO::PARAM_STR], + ":owner" => [$owner, PDO::PARAM_INT], + ] + ); + 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/Gateway/TeamGateway.php b/src/Core/Gateway/TeamGateway.php new file mode 100644 index 0000000..d775eda --- /dev/null +++ b/src/Core/Gateway/TeamGateway.php @@ -0,0 +1,85 @@ +con = $con; + } + + /** + * @param string $name + * @param string $picture + * @param string $mainColor + * @param string $secondColor + * @return int the inserted team identifier + */ + public function insert(string $name, string $picture, string $mainColor, string $secondColor): int { + $this->con->exec( + "INSERT INTO Team(name, picture, main_color, second_color) VALUES (:team_name , :picture, :main_color, :second_color)", + [ + ":team_name" => [$name, PDO::PARAM_STR], + ":picture" => [$picture, PDO::PARAM_STR], + ":main_color" => [$mainColor, PDO::PARAM_STR], + ":second_color" => [$secondColor, PDO::PARAM_STR], + ] + ); + return intval($this->con->lastInsertId()); + } + + + /** + * @param string $name + * @return TeamInfo[] + */ + public function listByName(string $name): array { + $result = $this->con->fetch( + "SELECT * FROM Team WHERE name LIKE '%' || :name || '%'", + [ + ":name" => [$name, PDO::PARAM_STR], + ] + ); + + return array_map(fn($row) => new TeamInfo($row['id'], $row['name'], $row['picture'], Color::from($row['main_color']), Color::from($row['second_color'])), $result); + } + + /** + * @param int $id + * @return TeamInfo + */ + public function getTeamById(int $id): ?TeamInfo { + $row = $this->con->fetch( + "SELECT * FROM Team WHERE id = :id", + [ + ":id" => [$id, PDO::PARAM_INT], + ] + )[0] ?? null; + if ($row == null) { + return null; + } + + return new TeamInfo($row['id'], $row['name'], $row['picture'], Color::from($row['main_color']), Color::from($row['second_color'])); + } + + /** + * @param string $name + * @return int|null + */ + public function getTeamIdByName(string $name): ?int { + return $this->con->fetch( + "SELECT id FROM Team WHERE name = :name", + [ + ":name" => [$name, PDO::PARAM_INT], + ] + )[0]['id'] ?? null; + } + + +} diff --git a/src/Http/HttpCodes.php b/src/Core/Http/HttpCodes.php similarity index 58% rename from src/Http/HttpCodes.php rename to src/Core/Http/HttpCodes.php index b41af8a..1903f0c 100644 --- a/src/Http/HttpCodes.php +++ b/src/Core/Http/HttpCodes.php @@ -1,13 +1,18 @@ + * */ class HttpRequest implements ArrayAccess { + /** + * @var array + */ private array $data; + /** + * @param array $data + */ private function __construct(array $data) { $this->data = $data; } @@ -17,9 +28,9 @@ class HttpRequest implements ArrayAccess { /** * 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 + * @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 { @@ -43,15 +54,28 @@ class HttpRequest implements ArrayAccess { 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."); } -} \ No newline at end of file +} diff --git a/src/Core/Http/HttpResponse.php b/src/Core/Http/HttpResponse.php new file mode 100644 index 0000000..eb52ccc --- /dev/null +++ b/src/Core/Http/HttpResponse.php @@ -0,0 +1,52 @@ + + */ + 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 { + 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/Http/JsonHttpResponse.php b/src/Core/Http/JsonHttpResponse.php similarity index 88% rename from src/Http/JsonHttpResponse.php rename to src/Core/Http/JsonHttpResponse.php index 9d7423f..bb897f7 100644 --- a/src/Http/JsonHttpResponse.php +++ b/src/Core/Http/JsonHttpResponse.php @@ -1,9 +1,8 @@ payload = $payload; } @@ -26,4 +25,4 @@ class JsonHttpResponse extends HttpResponse { return $result; } -} \ No newline at end of file +} diff --git a/src/Core/Model/AuthModel.php b/src/Core/Model/AuthModel.php new file mode 100644 index 0000000..a6ada53 --- /dev/null +++ b/src/Core/Model/AuthModel.php @@ -0,0 +1,80 @@ +gateway = $gateway; + } + + + /** + * @param string $username + * @param string $password + * @param string $confirmPassword + * @param string $email + * @param ValidationFail[] $failures + * @return Account|null the registered account or null if failures occurred + */ + public function register(string $username, string $password, string $confirmPassword, string $email, array &$failures): ?Account { + + if ($password != $confirmPassword) { + $failures[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes."); + } + + if ($this->gateway->exists($email)) { + $failures[] = new FieldValidationFail("email", "L'email existe déjà"); + } + + if (!empty($failures)) { + return null; + } + + $hash = password_hash($password, PASSWORD_DEFAULT); + + $token = $this->generateToken(); + $accountId = $this->gateway->insertAccount($username, $email, $token, $hash); + return new Account($email, $username, $token, $accountId); + } + + /** + * Generate a random base 64 string + * @return string + */ + private function generateToken(): string { + return base64_encode(random_bytes(64)); + } + + /** + * @param string $email + * @param string $password + * @param ValidationFail[] $failures + * @return Account|null the authenticated account or null if failures occurred + */ + public function login(string $email, string $password, array &$failures): ?Account { + $hash = $this->gateway->getHash($email); + if ($hash == null) { + $failures[] = new FieldValidationFail("email", "l'addresse email n'est pas connue."); + return null; + } + + if (!password_verify($password, $hash)) { + $failures[] = new FieldValidationFail("password", "Mot de passe invalide."); + return null; + } + + return $this->gateway->getAccountFromMail($email); + } + + +} diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php new file mode 100644 index 0000000..136b27d --- /dev/null +++ b/src/Core/Model/TacticModel.php @@ -0,0 +1,92 @@ +tactics = $tactics; + } + + /** + * creates a new empty tactic, with given name + * @param string $name + * @param int $ownerId + * @return TacticInfo + */ + public function makeNew(string $name, int $ownerId): TacticInfo { + $id = $this->tactics->insert($name, $ownerId); + return $this->tactics->get($id); + } + + /** + * creates a new empty tactic, with a default name + * @param int $ownerId + * @return TacticInfo|null + */ + public function makeNewDefault(int $ownerId): ?TacticInfo { + return $this->makeNew(self::TACTIC_DEFAULT_NAME, $ownerId); + } + + /** + * 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> + */ + public function getLast(int $nb): ?array { + return $this->tactics->getLast($nb); + } + + /** + * 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 new file mode 100644 index 0000000..f6af837 --- /dev/null +++ b/src/Core/Model/TeamModel.php @@ -0,0 +1,82 @@ +teams = $gateway; + $this->members = $members; + $this->users = $users; + } + + /** + * @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); + } + + /** + * adds a member to a team + * @param string $mail + * @param int $teamId + * @param string $role + * @return void + */ + public function addMember(string $mail, int $teamId, string $role): void { + $userId = $this->users->getAccountFromMail($mail)->getId(); + $this->members->insert($teamId, $userId, $role); + } + + /** + * @param string $name + * @return TeamInfo[] + */ + public function listByName(string $name): array { + return $this->teams->listByName($name); + } + + /** + * @param int $id + * @return Team + */ + public function getTeam(int $id): Team { + $teamInfo = $this->teams->getTeamById($id); + $members = $this->members->getMembersOfTeam($id); + return new Team($teamInfo, $members); + } + + + /** + * delete a member from given team identifier + * @param string $mail + * @param int $teamId + * @return int + */ + public function deleteMember(string $mail, int $teamId): int { + $userId = $this->users->getAccountFromMail($mail)->getId(); + $this->members->remove($teamId, $userId); + return $teamId; + } + +} diff --git a/src/Validation/ComposedValidator.php b/src/Core/Validation/ComposedValidator.php similarity index 74% rename from src/Validation/ComposedValidator.php rename to src/Core/Validation/ComposedValidator.php index 418b1ed..58f4910 100644 --- a/src/Validation/ComposedValidator.php +++ b/src/Core/Validation/ComposedValidator.php @@ -1,9 +1,8 @@ first->validate($name, $val); - $thenFailures = $this->then->validate($name, $val); + $thenFailures = []; + if (empty($firstFailures)) { + $thenFailures = $this->then->validate($name, $val); + } return array_merge($firstFailures, $thenFailures); } -} \ No newline at end of file +} diff --git a/src/Validation/FieldValidationFail.php b/src/Core/Validation/FieldValidationFail.php similarity index 88% rename from src/Validation/FieldValidationFail.php rename to src/Core/Validation/FieldValidationFail.php index 5b535f7..e3a127d 100644 --- a/src/Validation/FieldValidationFail.php +++ b/src/Core/Validation/FieldValidationFail.php @@ -1,7 +1,6 @@ + */ + public function jsonSerialize(): array { return ["field" => $this->fieldName, "message" => $this->getMessage()]; } -} \ No newline at end of file +} diff --git a/src/Validation/FunctionValidator.php b/src/Core/Validation/FunctionValidator.php similarity index 55% rename from src/Validation/FunctionValidator.php rename to src/Core/Validation/FunctionValidator.php index 6874d63..1bd18d7 100644 --- a/src/Validation/FunctionValidator.php +++ b/src/Core/Validation/FunctionValidator.php @@ -1,13 +1,15 @@ validate_fn = $validate_fn; @@ -16,4 +18,4 @@ class FunctionValidator extends Validator { public function validate(string $name, $val): array { return call_user_func_array($this->validate_fn, [$name, $val]); } -} \ No newline at end of file +} diff --git a/src/Validation/SimpleFunctionValidator.php b/src/Core/Validation/SimpleFunctionValidator.php similarity index 57% rename from src/Validation/SimpleFunctionValidator.php rename to src/Core/Validation/SimpleFunctionValidator.php index 079452d..f19462b 100644 --- a/src/Validation/SimpleFunctionValidator.php +++ b/src/Core/Validation/SimpleFunctionValidator.php @@ -1,18 +1,23 @@ bool`, to validate the given string - * @param callable $errorsFactory a factory function with signature `(string) => array` to emit failures when the predicate fails + * @param callable(mixed): bool $predicate a function predicate with signature: `(string) => 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; @@ -25,4 +30,4 @@ class SimpleFunctionValidator extends Validator { } return []; } -} \ No newline at end of file +} diff --git a/src/Validation/Validation.php b/src/Core/Validation/Validation.php similarity index 87% rename from src/Validation/Validation.php rename to src/Core/Validation/Validation.php index 4372380..5b13354 100644 --- a/src/Validation/Validation.php +++ b/src/Core/Validation/Validation.php @@ -1,17 +1,16 @@ 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/Validation/Validator.php b/src/Core/Validation/Validator.php similarity index 62% rename from src/Validation/Validator.php rename to src/Core/Validation/Validator.php index 6cdafb9..d1761da 100644 --- a/src/Validation/Validator.php +++ b/src/Core/Validation/Validator.php @@ -1,24 +1,23 @@ preg_match($regex, $str), + fn(string $name) => [new FieldValidationFail($name, $msg == null ? "le champ ne valide pas le pattern $regex" : $msg)] + ); + } + + public static function hex(?string $msg = null): Validator { + return self::regex('/#([0-9a-fA-F])/', $msg == null ? "le champ n'est pas un nombre hexadecimal valide" : $msg); + } + + public static function hexColor(?string $msg = null): Validator { + return self::regex('/#([0-9a-fA-F]{6})/', $msg == null ? "le champ n'est pas une couleur valide" : $msg); + } + + /** + * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-` and `_`. + */ + public static function name(?string $msg = null): Validator { + return self::regex("/^[0-9a-zA-Zà-üÀ-Ü_-]*$/", $msg); + } + + /** + * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-`, `_` and spaces. + */ + public static function nameWithSpaces(): Validator { + return self::regex("/^[0-9a-zA-Zà-üÀ-Ü _-]*$/"); + } + + /** + * Validate string if its length is between given range + * @param int $min minimum accepted length, inclusive + * @param int $max maximum accepted length, exclusive + * @return Validator + */ + public static function lenBetween(int $min, int $max): Validator { + return new FunctionValidator( + function (string $fieldName, string $str) use ($min, $max) { + $len = strlen($str); + if ($len >= $max) { + return [new FieldValidationFail($fieldName, "trop long, maximum $max caractères.")]; + } + if ($len < $min) { + return [new FieldValidationFail($fieldName, "trop court, minimum $min caractères.")]; + } + return []; + } + ); + } + + public static function email(?string $msg = null): Validator { + return new SimpleFunctionValidator( + fn(string $str) => filter_var($str, FILTER_VALIDATE_EMAIL), + fn(string $name) => [new FieldValidationFail($name, $msg == null ? "addresse mail invalide" : $msg)] + ); + } + + + public static function isInteger(): Validator { + return self::regex("/^[0-9]+$/"); + } + + public static function isIntInRange(int $min, int $max): Validator { + return new SimpleFunctionValidator( + fn(string $val) => intval($val) >= $min && intval($val) <= $max, + fn(string $name) => [new FieldValidationFail($name, "The value is not in the range $min to $max ")] + ); + } + + public static function isURL(): Validator { + return new SimpleFunctionValidator( + fn($val) => filter_var($val, FILTER_VALIDATE_URL), + fn(string $name) => [new FieldValidationFail($name, "The value is not an URL")] + ); + } +} diff --git a/src/Data/Account.php b/src/Data/Account.php deleted file mode 100755 index 155f2ae..0000000 --- a/src/Data/Account.php +++ /dev/null @@ -1,99 +0,0 @@ -email = $email; - $this->phoneNumber = $phoneNumber; - $this->user = $user; - $this->teams = $teams; - $this->id = $id; - } - - /** - * @return string - */ - public function getEmail(): string { - return $this->email; - } - - /** - * @param string $email - */ - public function setEmail(string $email): void { - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - throw new InvalidArgumentException("Invalid mail address"); - } - $this->email = $email; - } - - /** - * @return string - */ - public function getPhoneNumber(): string { - return $this->phoneNumber; - } - - /** - * @param string $phoneNumber - */ - public function setPhoneNumber(string $phoneNumber): void { - if (!filter_var($phoneNumber, FILTER_VALIDATE_REGEXP, PHONE_NUMBER_REGEXP)) { - throw new InvalidArgumentException("Invalid phone number"); - } - $this->phoneNumber = $phoneNumber; - } - - public function getId(): int { - return $this->id; - } - - public function getTeams(): array { - return $this->teams; - } - - public function getUser(): AccountUser { - return $this->user; - } -} \ No newline at end of file diff --git a/src/Data/AccountUser.php b/src/Data/AccountUser.php deleted file mode 100755 index 7808062..0000000 --- a/src/Data/AccountUser.php +++ /dev/null @@ -1,52 +0,0 @@ -name = $name; - $this->profilePicture = $profilePicture; - $this->age = $age; - } - - - public function getName(): string { - return $this->name; - } - - public function getProfilePicture(): Url { - return $this->profilePicture; - } - - public function getAge(): int { - return $this->age; - } - - public function setName(string $name) { - $this->name = $name; - } - - public function setProfilePicture(Url $profilePicture) { - $this->profilePicture = $profilePicture; - } - - public function setAge(int $age) { - $this->age = $age; - } - - -} \ No newline at end of file diff --git a/src/Data/Color.php b/src/Data/Color.php deleted file mode 100755 index f841731..0000000 --- a/src/Data/Color.php +++ /dev/null @@ -1,30 +0,0 @@ - 0xFFFFFF) { - throw new InvalidArgumentException("int color value is invalid, must be positive and lower than 0xFFFFFF"); - } - $this->value = $value; - } - - /** - * @return int - */ - public function getValue(): int { - return $this->value; - } -} \ No newline at end of file diff --git a/src/Data/MemberRole.php b/src/Data/MemberRole.php deleted file mode 100755 index 05d746d..0000000 --- a/src/Data/MemberRole.php +++ /dev/null @@ -1,40 +0,0 @@ -isValid($val)) { - throw new InvalidArgumentException("Valeur du rôle invalide"); - } - $this->value = $val; - } - - private function isValid(int $val): bool { - return ($val <= self::MAX and $val >= self::MIN); - } - - public function isPlayer(): bool { - return ($this->value == self::ROLE_PLAYER); - } - - public function isCoach(): bool { - return ($this->value == self::ROLE_COACH); - } - -} \ No newline at end of file diff --git a/src/Data/TacticInfo.php b/src/Data/TacticInfo.php deleted file mode 100644 index 901280d..0000000 --- a/src/Data/TacticInfo.php +++ /dev/null @@ -1,36 +0,0 @@ -id = $id; - $this->name = $name; - $this->creation_date = $creation_date; - } - - public function getId(): int { - return $this->id; - } - - public function getName(): string { - return $this->name; - } - - public function getCreationTimestamp(): int { - return $this->creation_date; - } - - public function jsonSerialize() { - return get_object_vars($this); - } -} \ No newline at end of file diff --git a/src/Data/Team.php b/src/Data/Team.php deleted file mode 100755 index 48643d9..0000000 --- a/src/Data/Team.php +++ /dev/null @@ -1,65 +0,0 @@ -name = $name; - $this->picture = $picture; - $this->mainColor = $mainColor; - $this->secondColor = $secondColor; - $this->members = $members; - } - - /** - * @return string - */ - public function getName(): string { - return $this->name; - } - - /** - * @return Url - */ - public function getPicture(): Url { - return $this->picture; - } - - /** - * @return Color - */ - public function getMainColor(): Color { - return $this->mainColor; - } - - /** - * @return Color - */ - public function getSecondColor(): Color { - return $this->secondColor; - } - - public function listMembers(): array { - return array_map(fn ($id, $role) => new Member($id, $role), $this->members); - } - -} \ No newline at end of file diff --git a/src/Data/User.php b/src/Data/User.php deleted file mode 100755 index 15c9995..0000000 --- a/src/Data/User.php +++ /dev/null @@ -1,27 +0,0 @@ -con = $con; - } - - - function insert(string $username, string $description) { - $this->con->exec( - "INSERT INTO FormEntries VALUES (:name, :description)", - [ - ":name" => [$username, PDO::PARAM_STR], - "description" => [$description, PDO::PARAM_STR] - ] - ); - } - - function listResults(): array { - return $this->con->fetch("SELECT * FROM FormEntries", []); - } -} \ No newline at end of file diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php deleted file mode 100644 index 20d2957..0000000 --- a/src/Gateway/TacticInfoGateway.php +++ /dev/null @@ -1,56 +0,0 @@ -con = $con; - } - - public function get(int $id): ?TacticInfo { - $res = $this->con->fetch( - "SELECT * FROM TacticInfo WHERE id = :id", - [":id" => [$id, PDO::PARAM_INT]] - ); - - if (!isset($res[0])) { - return null; - } - - $row = $res[0]; - - return new TacticInfo($id, $row["name"], strtotime($row["creation_date"])); - } - - public function insert(string $name): TacticInfo { - $this->con->exec( - "INSERT INTO TacticInfo(name) VALUES(:name)", - [":name" => [$name, PDO::PARAM_STR]] - ); - $row = $this->con->fetch( - "SELECT id, creation_date FROM TacticInfo WHERE :id = id", - [':id' => [$this->con->lastInsertId(), PDO::PARAM_INT]] - )[0]; - return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"])); - } - - public function updateName(int $id, string $name) { - $this->con->exec( - "UPDATE TacticInfo SET name = :name WHERE id = :id", - [ - ":name" => [$name, PDO::PARAM_STR], - ":id" => [$id, PDO::PARAM_INT] - ] - ); - } - -} \ No newline at end of file diff --git a/src/Http/HttpResponse.php b/src/Http/HttpResponse.php deleted file mode 100644 index 9f081a5..0000000 --- a/src/Http/HttpResponse.php +++ /dev/null @@ -1,24 +0,0 @@ -code = $code; - } - - public function getCode(): int { - return $this->code; - } - - public static function fromCode(int $code): HttpResponse { - return new HttpResponse($code); - } - -} \ No newline at end of file diff --git a/src/Model/TacticModel.php b/src/Model/TacticModel.php deleted file mode 100644 index c0b1ffe..0000000 --- a/src/Model/TacticModel.php +++ /dev/null @@ -1,54 +0,0 @@ -tactics = $tactics; - } - - public function makeNew(string $name): TacticInfo { - return $this->tactics->insert($name); - } - - public function makeNewDefault(): ?TacticInfo { - return $this->tactics->insert(self::TACTIC_DEFAULT_NAME); - } - - /** - * 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); - } - - /** - * Update the name of a tactic - * @param int $id the tactic identifier - * @param string $name the new name to set - * @return true if the update was done successfully - */ - public function updateName(int $id, string $name): bool { - if ($this->tactics->get($id) == null) { - return false; - } - - $this->tactics->updateName($id, $name); - return true; - } - -} \ No newline at end of file diff --git a/src/Validation/ValidationFail.php b/src/Validation/ValidationFail.php deleted file mode 100644 index fa5139c..0000000 --- a/src/Validation/ValidationFail.php +++ /dev/null @@ -1,35 +0,0 @@ -message = $message; - $this->kind = $kind; - } - - public function getMessage(): string { - return $this->message; - } - - public function getKind(): string { - return $this->kind; - } - - public function jsonSerialize() { - return ["error" => $this->kind, "message" => $this->message]; - } - - public static function notFound(string $message): ValidationFail { - return new ValidationFail("not found", $message); - } - -} \ No newline at end of file diff --git a/src/Validation/Validators.php b/src/Validation/Validators.php deleted file mode 100644 index ea9da46..0000000 --- a/src/Validation/Validators.php +++ /dev/null @@ -1,54 +0,0 @@ - preg_match($regex, $str), - fn(string $name) => [new FieldValidationFail($name, $msg == null ? "field does not validates pattern $regex" : $msg)] - ); - } - - /** - * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-` and `_`. - */ - public static function name($msg = null): Validator { - return self::regex("/^[0-9a-zA-Zà-üÀ-Ü_-]*$/", $msg); - } - - /** - * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-`, `_` and spaces. - */ - public static function nameWithSpaces(): Validator { - return self::regex("/^[0-9a-zA-Zà-üÀ-Ü _-]*$/"); - } - - /** - * Validate string if its length is between given range - * @param int $min minimum accepted length, inclusive - * @param int $max maximum accepted length, exclusive - * @return Validator - */ - public static function lenBetween(int $min, int $max): Validator { - return new FunctionValidator( - function (string $fieldName, string $str) use ($min, $max) { - $len = strlen($str); - if ($len >= $max) { - return [new FieldValidationFail($fieldName, "field is longer than $max chars.")]; - } - if ($len < $min) { - return [new FieldValidationFail($fieldName, "field is shorted than $min chars.")]; - } - return []; - } - ); - } -} \ No newline at end of file diff --git a/src/Views/error.html.twig b/src/Views/error.html.twig deleted file mode 100644 index 1d1db7d..0000000 --- a/src/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/Views/sample_form.html.twig b/src/Views/sample_form.html.twig deleted file mode 100644 index bcb958e..0000000 --- a/src/Views/sample_form.html.twig +++ /dev/null @@ -1,20 +0,0 @@ - - - - - Twig view - - - -

Hello, this is a sample form made in Twig !

- -
- - - - - -
- - - \ No newline at end of file diff --git a/public/utils.php b/src/index-utils.php similarity index 68% rename from public/utils.php rename to src/index-utils.php index a3566fe..eb600bc 100644 --- a/public/utils.php +++ b/src/index-utils.php @@ -3,18 +3,19 @@ /** * relative path of the public directory from the server's document root. */ -function get_public_path() { +function get_public_path(string $public_dir): string { // find the server path of the index.php file - $basePath = substr(__DIR__, strlen($_SERVER['DOCUMENT_ROOT'])); + $basePath = substr($public_dir, strlen($_SERVER['DOCUMENT_ROOT'])); $basePathLen = strlen($basePath); - if ($basePathLen == 0) + if ($basePathLen == 0) { return ""; - + } + $c = $basePath[$basePathLen - 1]; if ($c == "/" || $c == "\\") { $basePath = substr($basePath, 0, $basePathLen - 1); } return $basePath; -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 9da1fb5..d01f3cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "dom.iterable", "esnext" ], - "types": ["vite/client"], + "types": ["vite/client", "vite-plugin-svgr/client"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, diff --git a/verify.sh b/verify.sh new file mode 100755 index 0000000..314b8bc --- /dev/null +++ b/verify.sh @@ -0,0 +1,9 @@ +#!/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 bb04351..4ff1dc5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ relativeCSSInjection: true, }), svgr({ - include: "**/*.svg" + include: "**/*.svg?react" }) ] })