conflicts
continuous-integration/drone/push Build is failing Details

pull/40/head
Vivien DUFOUR 1 year ago
commit dac0c54cec

4
.gitignore vendored

@ -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

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

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

@ -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 lapi
- `src/App`, qui définit les contrôleurs et les vues de lapplication web
- `src/Core`, définit les modèles, les classes métiers, les librairies internes (validation, http), les gateways, en somme, les élements logiques de lapplication et les actions que lont peut faire avec.
- `sql`, définit la base de donnée utilisée, et éxécute les fichiers sql lorsque la base de donnée nest pas initialisée.
- `profiles`, définit les profiles dexecution, voir `Documentation/how-to-dev.md` pour plus dinfo
- `front` contient le code front-end react/typescript
- `ci` contient les scripts de déploiement et la définition du workflow dintégration continue et de déploiement constant vers notre staging server ([maxou.dev/<branch>/public/](https://maxou.dev/IQBall/master/public)).
- `public` point dentrée, avec :
- `public/index.php` point dentrée pour la webapp
- `public/api/index.php` point dentrée pour lapi.
## 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 dun ensemble de joueurs et dadversaires sur le terrain,
aillant leur position de départ, et leur position cible, avec des flèches représentant le type de mouvement (dribble, écran, etc) effectué.
les enfants dune étape, sont dautres étapes en fonction des cas de figures (si tel joueur fait tel mouvement, ou si tel joueur fait telle passe etc).
Pour rendre le tout agréable à utiliser, il faut que linterface soit réactive : si lon bouge un joueur,
il faut que les flèches qui y sont liés bougent aussi, il faut que les joueurs puissent bouger le long des flèches en mode visualiseur etc…
Le front-end de léditeur et du visualiseur étant assez ambitieux, et occupant une place importante du projet, nous avons décidés de leffectuer en utilisant
le framework React qui rend simple le développement dinterfaces dynamiques, et dutiliser typescript parce quici on code bien et quon impose une type safety a notre code.

@ -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)

@ -1,12 +0,0 @@
@startuml
class Connexion
class Modele
class Account
class AccountGateway
@enduml

@ -2,12 +2,10 @@
object Account {
<u>id
name
age
email
phoneNumber
passwordHash
profilePicture
username
token
hash
}
object Team {
@ -26,7 +24,7 @@ object TacticFolder {
object Tactic {
<u>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

@ -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 :
```

@ -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 {
+ <u>react(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse
}
note right of ViewHttpResponse
Into src/App
end note
@enduml

@ -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<int, MemberRole>
+ getName(): String
+ getPicture(): Url
class TeamInfo {
- creationDate: int
- name: string
- picture: string
+ getName(): string
+ getPicture(): string
+ getMainColor(): Color
+ getSecondColor(): Color
+ listMembers(): array<Member>
}
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

@ -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

@ -0,0 +1,63 @@
@startuml
class Team {
- name: string
- picture: Url
- members: array<int, MemberRole>
+ __construct(name : string, picture : string, mainColor : Colo, secondColor : Color)
+ getName(): string
+ getPicture(): Url
+ getMainColor(): Color
+ getSecondColor(): Color
+ listMembers(): array<Member>
}
Team --> "- mainColor" Color
Team --> "- secondColor" Color
class Color {
- value: string
- __construct(value : string)
+ getValue(): string
+ from(value: string): Color
+ tryFrom(value : string) : ?Color
}
class TeamGateway{
--
+ __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

@ -50,9 +50,11 @@ class Validation {
}
class Validators {
---
+ <u>nonEmpty(): Validator
+ <u>shorterThan(limit: int): Validator
+ <u>userString(maxLen: int): Validator
...
}

@ -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).

@ -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/

@ -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/

@ -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"
}
}
}

@ -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;

@ -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

@ -1,4 +1,4 @@
/**
* This constant defines the API endpoint.
*/
export const API = import.meta.env.VITE_API_ENDPOINT;
export const API = import.meta.env.VITE_API_ENDPOINT

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

@ -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 }
}

@ -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(
<React.StrictMode>
<Component {...args}/>
</React.StrictMode>
);
}
<Component {...args} />
</React.StrictMode>,
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

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

After

Width:  |  Height:  |  Size: 732 B

@ -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<E> {
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<E extends { key: string | number }> {
id: string
objects: E[]
onChange: (objects: E[]) => void
canDetach: (ref: HTMLDivElement) => boolean
onElementDetached: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
}
interface RackItemProps<E> {
item: E,
onTryDetach: (ref: HTMLDivElement, el: E) => void,
render: (e: E) => ReactElement,
interface RackItemProps<E extends { key: string | number }> {
item: E
onTryDetach: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
}
/**
* A container of draggable objects
* */
export function Rack<E>({id, objects, onChange, canDetach, onElementDetached, render}: RackProps<E>) {
export function Rack<E extends { key: string | number }>({
id,
objects,
onChange,
canDetach,
onElementDetached,
render,
}: RackProps<E>) {
return (
<div id={id} style={{
display: "flex"
}}>
{objects.map(element => (
<RackItem key={element.key}
item={element}
render={render}
onTryDetach={(ref, element) => {
if (!canDetach(ref))
return
<div
id={id}
style={{
display: "flex",
}}>
{objects.map((element) => (
<RackItem
key={element.key}
item={element}
render={render}
onTryDetach={(ref, 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)
}}
/>
))}
</div>
)
}
function RackItem<E>({item, onTryDetach, render}: RackItemProps<E>) {
const divRef = useRef<HTMLDivElement>(null);
function RackItem<E extends { key: string | number }>({
item,
onTryDetach,
render,
}: RackItemProps<E>) {
const divRef = useRef<HTMLDivElement>(null)
return (
<Draggable
position={{x: 0, y: 0}}
position={{ x: 0, y: 0 }}
nodeRef={divRef}
onStop={() => onTryDetach(divRef.current!, item)}>
<div ref={divRef}>
{render(item)}
</div>
<div ref={divRef}>{render(item)}</div>
</Draggable>
)
}
}

@ -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<HTMLInputElement>(null);
export default function TitleInput({
style,
default_value,
on_validated,
}: TitleInputOptions) {
const [value, setValue] = useState(default_value)
const ref = useRef<HTMLInputElement>(null)
return (
<input className="title_input"
ref={ref}
style={style}
type="text"
value={value}
onChange={event => setValue(event.target.value)}
onBlur={_ => on_validated(value)}
onKeyDown={event => {
if (event.key == 'Enter')
ref.current?.blur();
}}
<input
className="title-input"
ref={ref}
style={style}
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={(_) => on_validated(value)}
onKeyDown={(event) => {
if (event.key == "Enter") ref.current?.blur()
}}
/>
)
}
}

@ -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<ReactElement[]>([])
const divRef = useRef<HTMLDivElement>(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 (
<CourtPlayer key={player.id}
pos={player.position}
team={player.team}
x={(bounds.width * player.rightRatio)}
y={(bounds.height * player.bottomRatio)}
bounds={{bottom: bounds.height, top: 0, left: 0, right: bounds.width}}
onRemove={() => 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<HTMLDivElement>(null)
return (
<div id="court-container" ref={divRef}>
<CourtSvg id="court-svg"/>
{courtPlayers}
<div id="court-container" ref={divRef} style={{ position: "relative" }}>
<CourtSvg id="court-svg" />
{players.map((player) => {
return (
<CourtPlayer
key={player.team + player.role}
player={player}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
parentRef={divRef}
/>
)
})}
</div>
)
}

@ -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<HTMLDivElement>
}
/**
* 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<HTMLDivElement>(null)
const x = player.rightRatio
const y = player.bottomRatio
const ref = useRef<HTMLDivElement>(null);
return (
<Draggable
handle={".player-piece"}
nodeRef={ref}
bounds={bounds}
defaultPosition={{x, y}}
>
<div ref={ref}
className={"player"}
style={{
position: "absolute",
}}>
nodeRef={pieceRef}
bounds="parent"
position={{ x, y }}
onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = parentRef.current!.getBoundingClientRect()
const { x, y } = calculateRatio(pieceBounds, parentBounds)
<div tabIndex={0}
className="player-content"
onKeyUp={e => {
if (e.key == "Delete")
onRemove()
}}>
onChange({
rightRatio: x,
bottomRatio: y,
team: player.team,
role: player.role,
hasBall: false
})
}}>
<div
ref={pieceRef}
className={"player"}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div
tabIndex={0}
className="player-content"
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}>
<div className="player-selection-tab">
<RemoveIcon
className="player-selection-tab-remove"
onClick={() => onRemove()}/>
onClick={onRemove}
/>
</div>
<PlayerPiece team={team} text={pos}/>
<PlayerPiece team={player.team} text={player.role} />
</div>
</div>
</Draggable>
)
}
}

@ -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 (
<div className={`player-piece ${team}`}>
<p>{text}</p>
</div>
)
}
}

@ -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 (
<div className={"save-state"}>
<div className={state.className}>{state.message}</div>
</div>
)
}

@ -1,5 +1,3 @@
#court-container {
display: flex;
@ -12,8 +10,6 @@
-webkit-user-drag: none;
}
#court-svg * {
stroke: var(--selected-team-secondarycolor);
}
}

@ -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
}
--selection-color: #3f7fc4;
}

@ -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;
}

@ -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;
}
}

@ -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;
}

@ -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%;
}

@ -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)

@ -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
}
}

@ -0,0 +1,11 @@
import { Player } from "./Player"
export interface Tactic {
id: number
name: string
content: TacticContent
}
export interface TacticContent {
players: Player[]
}

@ -0,0 +1,4 @@
export enum Team {
Allies = "allies",
Opponents = "opponents",
}

@ -1,19 +0,0 @@
interface DisplayResultsProps {
results: readonly { name: string, description: string}[]
}
export default function DisplayResults({results}: DisplayResultsProps) {
const list = results
.map(({name, description}) =>
<div>
<p>username: {name}</p>
<p>description: {description}</p>
</div>
)
return (
<div>
{list}
</div>
)
}

@ -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<SaveState>
onNameChange: (name: string) => Promise<boolean>
}
/**
* 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<CSSProperties>({});
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 (
<EditorView
tactic={{ name: editorName, id, content: JSON.parse(editorContent) }}
onContentChange={async (content: TacticContent) => {
if (isInGuestMode) {
localStorage.setItem(
GUEST_MODE_CONTENT_STORAGE_KEY,
JSON.stringify(content),
)
return SaveStates.Guest
}
return fetchAPI(`tactic/${id}/save`, { content }).then((r) =>
r.ok ? SaveStates.Ok : SaveStates.Err,
)
}}
onNameChange={async (name: string) => {
if (isInGuestMode) {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}
return fetchAPI(`tactic/${id}/edit/name`, { name }).then(
(r) => r.ok,
)
}}
/>
)
}
function EditorView({
tactic: { id, name, content: initialContent },
onContentChange,
onNameChange,
}: EditorViewProps) {
const isInGuestMode = id == -1
const [style, setStyle] = useState<CSSProperties>({})
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<Ball[]>([]);
const [players, setPlayers] = useState<Player[]>([]);
const courtDivContentRef = useRef<HTMLDivElement>(null);
const courtDivContentRef = useRef<HTMLDivElement>(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<HTMLDivElement>, 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 (
<div id="main-div">
<div id="topbar-div">
<div>LEFT</div>
<TitleInput style={style} default_value={name} on_validated={new_name => {
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)
}
})
}}/>
<div>RIGHT</div>
<div id="topbar-left">
LEFT
<SavingState state={saveState} />
</div>
<div id="title-input-div">
<TitleInput
style={style}
default_value={name}
on_validated={(new_name) => {
onNameChange(new_name).then((success) => {
setStyle(success ? {} : ERROR_STYLE)
})
}}
/>
</div>
<div id="topbar-right">RIGHT</div>
</div>
<div id="edit-div">
<div id="racks">
<Rack id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={canDetach}
onElementDetached={onElementDetach}
render={({team, key}) => <PlayerPiece team={team} text={key} key={key}/>}/>
<Rack id="ball-rack"
objects={ballPiece}
onChange={setBallPiece}
<Rack
id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
)}
/>
<Rack
id="ball-rack"
objects={ball}
onChange={setBall}
canDetach={canDetachBall}
onElementDetached={onElementDetachBall}
render={({pos}) => <BallPiece key={pos}/>}/>
<Rack id="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={canDetach}
onElementDetached={onElementDetach}
render={({team, key}) => <PlayerPiece team={team} text={key} key={key}/>}/>
<Rack
id="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
)}
/>
</div>
<div id="court-div">
<div id="court-div-bounds" ref={courtDivContentRef}>
<BasketCourt
players={players}
ball={ball}
players={content.players}
onPlayerChange={(player) => {
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,
},
])
}}
/>
</div>
</div>
</div>
@ -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<S>(
initialContent: S,
initialSaveState: SaveState,
saveStateCallback: (s: S) => Promise<SaveState>,
): [S, Dispatch<SetStateAction<S>>, SaveState] {
const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback(
(newState: SetStateAction<S>) => {
setContent((content) => {
const state =
typeof newState === "function"
? (newState as (state: S) => S)(content)
: newState
if (state !== content) {
setSavingState(SaveStates.Saving)
saveStateCallback(state)
.then(setSavingState)
.catch(() => setSavingState(SaveStates.Err))
}
return state
})
},
[saveStateCallback],
)
return [content, setContentSynced, savingState]
}
function toSplicedPlayers(
players: Player[],
player: Player,
replace: boolean,
): Player[] {
const idx = players.findIndex(
(p) => p.team === player.team && p.role === player.role,
)
return players.toSpliced(idx, 1, ...(replace ? [player] : []))
}

@ -1,19 +0,0 @@
export default function SampleForm() {
return (
<div>
<h1>Hello, this is a sample form made in react !</h1>
<form action="submit" method="POST">
<label>your name: </label>
<input type="text" id="name" name="name"/>
<label>a little description about yourself: </label>
<input type="text" id="password" name="description"/>
<input type="submit" value="click me to submit!"/>
</form>
</div>
)
}

@ -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<CSSProperties>({})
return (
<div id="main">
<div id="topbar">
<h1>{name}</h1>
</div>
<div id="court-container">
<img
id="court"
src={Court}
style={style}
alt="Basketball Court"
/>
</div>
</div>
)
}

@ -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"
}
}

@ -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

@ -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;
}

@ -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);
}
}

@ -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.");
}
Api::render(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization()));

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

@ -1,76 +1,124 @@
<?php
require "../vendor/autoload.php";
require "../config.php";
require "../sql/database.php";
require "utils.php";
use App\Connexion;
use App\Controller\EditorController;
use App\Controller\SampleFormController;
use App\Gateway\FormResultGateway;
use App\Gateway\TacticInfoGateway;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
require "../src/App/react-display.php";
require "../src/index-utils.php";
use IQBall\App\App;
use IQBall\App\Controller\AuthController;
use IQBall\App\Controller\EditorController;
use IQBall\App\Controller\TeamController;
use IQBall\App\Controller\UserController;
use IQBall\App\Controller\VisualizerController;
use IQBall\App\Session\MutableSessionHandle;
use IQBall\App\Session\PhpSessionHandle;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Action;
use IQBall\Core\Connection;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Gateway\MemberGateway;
use IQBall\Core\Gateway\TacticInfoGateway;
use IQBall\Core\Gateway\TeamGateway;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Validation\ValidationFail;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use App\Validation\ValidationFail;
use App\Controller\ErrorController;
use Twig\TwigFunction;
function getConnection(): Connection {
return new Connection(get_database());
}
$loader = new FilesystemLoader('../src/Views/');
$twig = new \Twig\Environment($loader);
function getUserController(): UserController {
return new UserController(new TacticModel(new TacticInfoGateway(getConnection())));
}
$basePath = get_public_path();
$con = new Connexion(get_database());
function getVisualizerController(): VisualizerController {
return new VisualizerController(new TacticModel(new TacticInfoGateway(getConnection())));
}
// routes initialization
$router = new AltoRouter();
$router->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();
}
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());

@ -25,6 +25,3 @@ function get_database(): PDO {
return $pdo;
}

@ -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
);
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)
);

@ -0,0 +1,55 @@
<?php
namespace IQBall\Api;
use Exception;
use IQBall\Core\Action;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Validation\ValidationFail;
class API {
public static function render(HttpResponse $response): void {
http_response_code($response->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<string, mixed> $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);
}
}

@ -0,0 +1,44 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\App\Control;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Validation\Validators;
class APIAuthController {
private AuthModel $model;
/**
* @param AuthModel $model
*/
public function __construct(AuthModel $model) {
$this->model = $model;
}
/**
* From given email address and password, authenticate the user and respond with its authorization token.
* @return HttpResponse
*/
public function authorize(): HttpResponse {
return 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()]);
});
}
}

@ -0,0 +1,65 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\App\Control;
use IQBall\Core\Data\Account;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Validation\FieldValidationFail;
use IQBall\Core\Validation\Validators;
/**
* API endpoint related to tactics
*/
class APITacticController {
private TacticModel $model;
/**
* @param TacticModel $model
*/
public function __construct(TacticModel $model) {
$this->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);
});
}
}

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

@ -1,61 +1,48 @@
<?php
namespace App\Controller;
namespace IQBall\App;
use App\Http\HttpCodes;
use App\Http\HttpRequest;
use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Validation\ValidationFail;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Validation\ValidationFail;
use IQBall\Core\Validation\Validator;
class Control {
/**
* Runs given callback, if the request's json validates the given schema.
* @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<string, Validator[]> $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<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $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]);
}
}
}

@ -0,0 +1,87 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\MutableSessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Validation\Validators;
class AuthController {
private AuthModel $model;
/**
* @param AuthModel $model
*/
public function __construct(AuthModel $model) {
$this->model = $model;
}
public function displayRegister(): HttpResponse {
return ViewHttpResponse::twig("display_register.html.twig", []);
}
/**
* registers given account
* @param mixed[] $request
* @param MutableSessionHandle $session
* @return HttpResponse
*/
public function register(array $request, MutableSessionHandle $session): HttpResponse {
$fails = [];
$request = HttpRequest::from($request, $fails, [
"username" => [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");
}
}

@ -0,0 +1,76 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\Validator\TacticValidator;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Data\TacticInfo;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Model\TacticModel;
class EditorController {
private TacticModel $model;
public function __construct(TacticModel $model) {
$this->model = $model;
}
/**
* @param TacticInfo $tactic
* @return ViewHttpResponse the editor view for given tactic
*/
private function openEditorFor(TacticInfo $tactic): ViewHttpResponse {
return ViewHttpResponse::react("views/Editor.tsx", [
"id" => $tactic->getId(),
"name" => $tactic->getName(),
"content" => $tactic->getContent(),
]);
}
/**
* @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);
}
}

@ -0,0 +1,155 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\TeamModel;
use IQBall\Core\Validation\FieldValidationFail;
use IQBall\Core\Validation\Validators;
class TeamController {
private TeamModel $model;
/**
* @param TeamModel $model
*/
public function __construct(TeamModel $model) {
$this->model = $model;
}
/**
* @param SessionHandle $session
* @return ViewHttpResponse the team creation panel
*/
public function displayCreateTeam(SessionHandle $session): ViewHttpResponse {
return ViewHttpResponse::twig("insert_team.html.twig", []);
}
/**
* @param SessionHandle $session
* @return ViewHttpResponse the team panel to 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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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);
}
}

@ -0,0 +1,36 @@
<?php
namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Model\TacticModel;
class UserController {
private TacticModel $tactics;
/**
* @param TacticModel $tactics
*/
public function __construct(TacticModel $tactics) {
$this->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", []);
}
}

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

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

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

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

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

@ -1,9 +1,11 @@
<?php
namespace App\Http;
namespace IQBall\App;
class ViewHttpResponse extends HttpResponse {
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpResponse;
class ViewHttpResponse extends HttpResponse {
public const TWIG_VIEW = 0;
public const REACT_VIEW = 1;
@ -12,7 +14,7 @@ class ViewHttpResponse extends HttpResponse {
*/
private string $file;
/**
* @var array View arguments
* @var array<string, mixed> 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<string, mixed> $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<string, string>
*/
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<string, mixed> $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<string, mixed> $arguments
* @param int $code
* @return ViewHttpResponse
*/
@ -67,4 +72,4 @@ class ViewHttpResponse extends HttpResponse {
return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code);
}
}
}

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

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

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

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

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

@ -0,0 +1,102 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>S'enregistrer</title>
</head>
<body>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.error-messages {
color: #ff331a;
font-style: italic;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
{% for err in fails %}
.form-group
#
{{ err.getFieldName() }}
{
border-color: red
;
}
{% endfor %}
</style>
<div class="container">
<center><h2>S'enregistrer</h2></center>
<form action="{{ path('/register') }}" method="post">
<div class="form-group">
{% for name in fails %}
<label class="error-messages"> {{ name.getFieldName() }} : {{ name.getMessage() }} </label>
{% endfor %}
<label for="username">Nom d'utilisateur :</label>
<input type="text" id="username" name="username" required>
<label for="password">Mot de passe :</label>
<input type="password" id="password" name="password" required>
<label for="confirmpassword">Confirmer le mot de passe :</label>
<input type="password" id="confirmpassword" name="confirmpassword" required>
<label for="email">Email :</label>
<input type="text" id="email" name="email" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,4 +1,3 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@ -14,5 +13,6 @@
<p>description: {{ v.description }}</p>
{% endfor %}
</body>
</html>

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Twig view</title>
<style>
body {
background-color: #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
}
section {
width: 60%;
}
.square {
width: 50px;
height: 50px;
}
#main_color {
border: solid;
background-color: {{ team.getInfo().getMainColor().getValue() }};
}
#second_color {
background-color: {{ team.getInfo().getSecondColor().getValue() }};
border: solid;
}
.container {
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
}
.team {
border-color: darkgrey;
border-radius: 20px;
}
.color {
flex-direction: row;
justify-content: space-between;
}
.logo {
height: 80px;
width: 80px;
}
</style>
</head>
<body>
<header>
<h1><a href="{{ path('/') }}">IQBall</a></h1>
</header>
<section class="container">
<div class="team container">
<div>
<h1>{{ team.getInfo().getName() }}</h1>
<img src="{{ team.getInfo().getPicture() }}" alt="Logo d'équipe" class="logo">
</div>
<div>
<div class="color"><p>Couleur principale : </p>
<div class="square" id="main_color"></div>
</div>
<div class="color"><p>Couleur secondaire : </p>
<div class="square" id="second_color"></div>
</div>
</div>
{% for m in team.listMembers() %}
<p> {{ m.getUserId() }} </p>
{% if m.getRole().isCoach() %}
<p> : Coach</p>
{% else %}
<p> : Joueur</p>
{% endif %}
{% endfor %}
</div>
</section>
</body>
</html>

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Twig view</title>
</head>
<body>
{% if teams is empty %}
<p>Aucune équipe n'a été trouvée</p>
<div class="container">
<h2>Chercher une équipe</h2>
<form action="{{ path('/team/search') }}" method="post">
<div class="form-group">
<label for="name">Nom de l'équipe :</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
{% else %}
{% for t in teams %}
<div class="team" onclick="window.location.href = '{{ path("/team/#{t.id}") }}'">
<p>Nom de l'équipe : {{ t.name }}</p>
<img src="{{ t.picture }}" alt="logo de l'équipe">
</div>
{% endfor %}
{% endif %}
</body>
</html>

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

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

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

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

@ -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<string, mixed> $arguments arguments to pass to the rendered react component
* The arguments must be a json-encodable key/value dictionary.
* @return void
*/
function send_react_front(string $url, array $arguments) {
// the $url and $argument values are used into the included file
require_once "react-display-file.php";
}
}

@ -1,53 +0,0 @@
<?php
namespace App;
use \PDO;
class Connexion {
private PDO $pdo;
/**
* @param PDO $pdo
*/
public function __construct(PDO $pdo)
{
$this->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;
}
}

@ -1,55 +0,0 @@
<?php
namespace App\Controller\Api;
use App\Controller\Control;
use App\Http\HttpCodes;
use App\Http\HttpRequest;
use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Model\TacticModel;
use App\Validation\Validators;
/**
* API endpoint related to tactics
*/
class APITacticController {
private TacticModel $model;
/**
* @param TacticModel $model
*/
public function __construct(TacticModel $model) {
$this->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);
}
}

@ -1,48 +0,0 @@
<?php
namespace App\Controller;
use App\Data\TacticInfo;
use App\Http\HttpCodes;
use App\Http\HttpRequest;
use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
class EditorController {
private TacticModel $model;
/**
* @param TacticModel $model
*/
public function __construct(TacticModel $model) {
$this->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);
}
}

@ -1,20 +0,0 @@
<?php
namespace App\Controller;
require_once __DIR__ . "/../react-display.php";
use \Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
class ErrorController
{
public static function displayFailures(array $failures, Environment $twig) {
try {
$twig->display("error.html.twig", ['failures' => $failures]);
} catch (LoaderError | RuntimeError | SyntaxError $e) {
echo "Twig error: $e";
}
}
}

@ -1,52 +0,0 @@
<?php
namespace App\Controller;
require_once __DIR__ . "/../react-display.php";
use App\Gateway\FormResultGateway;
use App\Http\HttpRequest;
use App\Http\HttpResponse;
use App\Http\ViewHttpResponse;
use App\Validation\Validators;
class SampleFormController {
private FormResultGateway $gateway;
/**
* @param FormResultGateway $gateway
*/
public function __construct(FormResultGateway $gateway) {
$this->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));
}
}

@ -0,0 +1,58 @@
<?php
namespace IQBall\Core;
use IQBall\Core\Http\HttpResponse;
/**
* Represent an action.
* @template S session
*/
class Action {
/**
* @var callable(mixed[], S): HttpResponse $action action to call
*/
protected $action;
private bool $isAuthRequired;
/**
* @param callable(mixed[], S): HttpResponse $action
*/
protected function __construct(callable $action, bool $isAuthRequired) {
$this->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<S> 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<S> an action that does require to have an authorization.
*/
public static function auth(callable $action): Action {
return new Action($action, true);
}
}

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

@ -0,0 +1,61 @@
<?php
namespace IQBall\Core\Data;
/**
* Base class of a user account.
* Contains the private information that we don't want
* to share to other users, or non-needed public information
*/
class Account {
/**
* @var string $email account's mail address
*/
private string $email;
/**
* @var string string token
*/
private string $token;
/**
* @var string the account's username
*/
private string $name;
/**
* @var int
*/
private int $id;
/**
* @param string $email
* @param string $name
* @param string $token
* @param int $id
*/
public function __construct(string $email, string $name, string $token, int $id) {
$this->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;
}
}

@ -0,0 +1,44 @@
<?php
namespace IQBall\Core\Data;
use InvalidArgumentException;
class Color {
/**
* @var string that represents an hexadecimal color code
*/
private string $hex;
/**
* @param string $value 6 bytes unsigned int that represents an RGB color
* @throws InvalidArgumentException if the value is negative or greater than 0xFFFFFF
*/
private function __construct(string $value) {
$this->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);
}
}

@ -1,16 +1,21 @@
<?php
namespace App\Data;
namespace IQBall\Core\Data;
/**
* information about a team member
*/
class Member {
/**
* @var int The member's user id
* @var int The member's user account
*/
private int $userId;
/**
* @var int The member's team id
*/
private int $teamId;
/**
* @var MemberRole the member's role
*/
@ -20,11 +25,13 @@ class Member {
* @param int $userId
* @param MemberRole $role
*/
public function __construct(int $userId, MemberRole $role) {
public function __construct(int $userId, int $teamId, MemberRole $role) {
$this->userId = $userId;
$this->teamId = $teamId;
$this->role = $role;
}
/**
* @return int
*/
@ -39,4 +46,10 @@ class Member {
return $this->role;
}
}
/**
* @return int
*/
public function getTeamId(): int {
return $this->teamId;
}
}

@ -0,0 +1,68 @@
<?php
namespace IQBall\Core\Data;
use InvalidArgumentException;
/**
* Enumeration class workaround
* As there is no enumerations in php 7.4, this class
* encapsulates an integer value and use it as a variant discriminant
*/
final class MemberRole {
private const ROLE_PLAYER = 0;
private const ROLE_COACH = 1;
private const MIN = self::ROLE_PLAYER;
private const MAX = self::ROLE_COACH;
private int $value;
private function __construct(int $val) {
if (!$this->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);
}
}

@ -0,0 +1,56 @@
<?php
namespace IQBall\Core\Data;
class TacticInfo {
private int $id;
private string $name;
private int $creationDate;
private int $ownerId;
private string $content;
/**
* @param int $id
* @param string $name
* @param int $creationDate
* @param int $ownerId
* @param string $content
*/
public function __construct(int $id, string $name, int $creationDate, int $ownerId, string $content) {
$this->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;
}
}

@ -0,0 +1,32 @@
<?php
namespace IQBall\Core\Data;
class Team {
private TeamInfo $info;
/**
* @var Member[] maps users with their role
*/
private array $members;
/**
* @param TeamInfo $info
* @param Member[] $members
*/
public function __construct(TeamInfo $info, array $members = []) {
$this->info = $info;
$this->members = $members;
}
public function getInfo(): TeamInfo {
return $this->info;
}
/**
* @return Member[]
*/
public function listMembers(): array {
return $this->members;
}
}

@ -0,0 +1,49 @@
<?php
namespace IQBall\Core\Data;
class TeamInfo {
private int $id;
private string $name;
private string $picture;
private Color $mainColor;
private Color $secondColor;
/**
* @param int $id
* @param string $name
* @param string $picture
* @param Color $mainColor
* @param Color $secondColor
*/
public function __construct(int $id, string $name, string $picture, Color $mainColor, Color $secondColor) {
$this->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;
}
}

@ -0,0 +1,85 @@
<?php
namespace IQBall\Core\Gateway;
use IQBall\Core\Connection;
use IQBall\Core\Data\Account;
use PDO;
class AccountGateway {
private Connection $con;
/**
* @param Connection $con
*/
public function __construct(Connection $con) {
$this->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<string, mixed>|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"]);
}
}

@ -0,0 +1,69 @@
<?php
namespace IQBall\Core\Gateway;
use IQBall\Core\Connection;
use IQBall\Core\Data\Member;
use IQBall\Core\Data\MemberRole;
use PDO;
class MemberGateway {
private Connection $con;
/**
* @param Connection $con
*/
public function __construct(Connection $con) {
$this->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],
]
);
}
}

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

Loading…
Cancel
Save