From a123145acdf07a2aab499c1177f93ca0f23cf032 Mon Sep 17 00:00:00 2001 From: "mael.daim" Date: Tue, 9 Jan 2024 17:28:45 +0100 Subject: [PATCH 01/44] new branch where the documentation/conception will be updated --- Documentation/models.puml | 43 +++++++++++++++++++------------------- src/Core/Data/TeamInfo.php | 3 --- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/Documentation/models.puml b/Documentation/models.puml index 86ca699..727b2e3 100755 --- a/Documentation/models.puml +++ b/Documentation/models.puml @@ -29,18 +29,11 @@ class Account { class Member { - userId: int - teamId: int - - + __construct(role : MemberRole) + - role : string + + __construct(role : string) + getUserId(): int + getTeamId(): int - + getRole(): MemberRole -} - -Member --> "- role" MemberRole - -enum MemberRole { - PLAYER - COACH + + getRole(): string } @@ -48,28 +41,34 @@ class TeamInfo { - creationDate: int - name: string - picture: string - + - mainColor : string + - secondColor : string + getName(): string + getPicture(): string - + getMainColor(): Color - + getSecondColor(): Color + + getMainColor(): string + + getSecondColor(): string } -TeamInfo --> "- mainColor" Color -TeamInfo --> "- secondaryColor" Color - class Team { - getInfo(): TeamInfo - listMembers(): Member[] + + __construct() + + getInfo(): TeamInfo + + listMembers(): Member[] } Team --> "- info" TeamInfo Team --> "- members *" Member -class Color { - - value: int - - + getValue(): int +class User{ + - id : int + - name : string + - email : string + - profilePicture : string + + + __construct(id : int,name : string,email: string,profilePicture:string) + + getId() : id + + getName() : string + + getEmail() : string + + getProfilePicture() : string } @enduml \ No newline at end of file diff --git a/src/Core/Data/TeamInfo.php b/src/Core/Data/TeamInfo.php index 0f741fe..964990c 100644 --- a/src/Core/Data/TeamInfo.php +++ b/src/Core/Data/TeamInfo.php @@ -24,7 +24,6 @@ class TeamInfo implements \JsonSerializable { $this->secondColor = $secondColor; } - public function getId(): int { return $this->id; } @@ -48,6 +47,4 @@ class TeamInfo implements \JsonSerializable { public function jsonSerialize() { return get_object_vars($this); } - - } From 0640b9e46e91726abe78532f0dd9e35ac9bcef12 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 11 Jan 2024 19:27:55 +0100 Subject: [PATCH 02/44] fix account.svg logo --- front/views/template/Header.tsx | 4 ++-- public/account.svg | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 public/account.svg diff --git a/front/views/template/Header.tsx b/front/views/template/Header.tsx index 2618d90..4bbd538 100644 --- a/front/views/template/Header.tsx +++ b/front/views/template/Header.tsx @@ -1,5 +1,5 @@ import { BASE } from "../../Constants" - +import accountSvg from "../../assets/account.svg" /** * * @param param0 username @@ -25,7 +25,7 @@ export function Header({ username }: { username: string }) { {/* */} { location.pathname = BASE + "/settings" }} diff --git a/public/account.svg b/public/account.svg deleted file mode 100644 index 70d7391..0000000 --- a/public/account.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From e1056f4ade452bda355072350c0b455cf583ddf9 Mon Sep 17 00:00:00 2001 From: "mael.daim" Date: Fri, 12 Jan 2024 11:34:29 +0100 Subject: [PATCH 03/44] updated old documentation --- Documentation/models.puml | 48 ++++++++++++----- Documentation/mvc/auth.puml | 32 ++++++------ Documentation/mvc/editor.puml | 38 ++++++++++++++ Documentation/mvc/team.puml | 96 +++++++++++++++++++++------------- Documentation/validation.puml | 30 ++++++++--- src/Core/Model/TacticModel.php | 8 --- 6 files changed, 172 insertions(+), 80 deletions(-) create mode 100644 Documentation/mvc/editor.puml diff --git a/Documentation/models.puml b/Documentation/models.puml index 727b2e3..b88b543 100755 --- a/Documentation/models.puml +++ b/Documentation/models.puml @@ -6,51 +6,75 @@ class TacticInfo { - creationDate: string - ownerId: string - content: string - + + + __construct(id:int,name:string,creationDate:int,ownerId:int,courtType:CourtType,content:string) + getId(): int + getOwnerId(): int + getCreationTimestamp(): int + getName(): string + getContent(): string + + getCourtType() : CourtType +} + +TacticInfo -->"- courtType" CourtType + +class CourtType{ + - value : int + - COURT_PLAIN : int {static} {frozen} + - COURT_HALF : int {static} {frozen} + + - __construct(val:int) + + plain() : CourtType {static} + + half() : CourtType {static} + + name() : string + + fromName(name:string) : CourtType + + isPlain() : bool + + isHalf() : bool } +note bottom: Basically an evoluated enum + class Account { - - email: string - token: string - - name: string - - id: int - + getMailAddress(): string - + getToken(): string - + getName(): string - + getId(): int + + __construct(token:string,user:User) + + getUser() : User + + getToken() : string } +Account -->"- user" User + class Member { - - userId: int - teamId: int - role : string + + __construct(role : string) - + getUserId(): int + + getUser(): User + getTeamId(): int + getRole(): string } +note bottom: Member's role is either "Coach" or "Player" + +Member -->"- user" User class TeamInfo { - - creationDate: int - name: string - picture: string - mainColor : string - secondColor : string + + + __construct(id:int,name:string,picture:string,mainColor:string,secondColor:string) + getName(): string + getPicture(): string + getMainColor(): string + getSecondColor(): string } +note left: Both team's color are the hex code of the color + class Team { - + __construct() + + __construct(info:TeamInfo,members: Member[]) + getInfo(): TeamInfo + listMembers(): Member[] } diff --git a/Documentation/mvc/auth.puml b/Documentation/mvc/auth.puml index 8a2bb1f..d6b56fa 100644 --- a/Documentation/mvc/auth.puml +++ b/Documentation/mvc/auth.puml @@ -7,26 +7,26 @@ class AuthController { + displayLogin() : HttpResponse + login(request : array , session : MutableSessionHandle) : HttpResponse } -AuthController --> "- model" AuthModel +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) + + __construct(gateway : AccountGateway) + + register(username:string, password:string, confirmPassword:string, email:string, &failures:array): ?Account + generateToken() : string + + generateToken(): string + + login(email:string, password:string, &failures:array): ?Account } -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 +AuthModel *-- "- gateway" AccountGateway +class AccountGateway{ + + __construct(con : Connexion) + + insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): int + + getRowsFromMail(email:string): ?array + + getHash(email:string): ?string + + exists(email:string): bool + + getAccountFromMail(email:string): ?Account + + getAccountFromToken(token:string): ?Account } +AccountGateway *--"- con" Connexion + @enduml \ No newline at end of file diff --git a/Documentation/mvc/editor.puml b/Documentation/mvc/editor.puml new file mode 100644 index 0000000..d684e5b --- /dev/null +++ b/Documentation/mvc/editor.puml @@ -0,0 +1,38 @@ +@startuml +class EditorController { + +__construct (model : TacticModel) + + openEditorFor(tactic:TacticInfo): ViewHttpResponse + + createNew(): ViewHttpResponse + + openTestEditor(courtType:CourtType): ViewHttpResponse + + createNewOfKind(type:CourtType, session:SessionHandle): ViewHttpResponse + + openEditor(id:int, session:SessionHandle): ViewHttpResponse +} +EditorController *-- "- model" TacticModel + +class TacticModel { + + TACTIC_DEFAULT_NAME:int {static}{frozen} + + __construct(tactics : TacticInfoGateway) + + makeNew(name:string, ownerId:int, type:CourtType): TacticInfo + + makeNewDefault(ownerId:int, type:CourtType): ?TacticInfo + + get(id:int): ?TacticInfo + + getLast(nb:int, ownerId:int): array + + getAll(ownerId:int): ?array + + updateName(id:int, name:string, authId:int): array + + updateContent(id:int, json:string): ?ValidationFail +} + +TacticModel *-- "- tactics" TacticInfoGateway + +class TacticInfoGateway{ + + __construct(con : Connexion) + + get(id:int): ?TacticInfo + + getLast(nb:int, ownerId:int): ?array + + getAll(ownerId:int): ?array + + insert(name:string, owner:int, type:CourtType): int + + updateName(id:int, name:string): bool + + updateContent(id:int, json:string): bool +} + +TacticInfoGateway *--"- con" Connexion + +@enduml \ No newline at end of file diff --git a/Documentation/mvc/team.puml b/Documentation/mvc/team.puml index ad5e201..8762ffe 100644 --- a/Documentation/mvc/team.puml +++ b/Documentation/mvc/team.puml @@ -1,63 +1,87 @@ @startuml -class Team { - - name: string - - picture: Url - - members: array - - + __construct(name : string, picture : string, mainColor : Colo, secondColor : Color) - + getName(): string - + getPicture(): Url - + getMainColor(): Color - + getSecondColor(): Color - + listMembers(): array -} -Team --> "- mainColor" Color -Team --> "- secondColor" Color -class Color { - - value: string - - __construct(value : string) - + getValue(): string - + from(value: string): Color - + tryFrom(value : string) : ?Color -} class TeamGateway{ - -- + + __construct(con : Connexion) + insert(name : string ,picture : string, mainColor : Color, secondColor : Color) + listByName(name : string): array + + getTeamById(id:int): ?TeamInfo + + getTeamIdByName(name:string): ?int + + deleteTeam(idTeam:int): void + + editTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string) + + getAll(user:int): array } TeamGateway *--"- con" Connexion -TeamGateway ..> Color + + +class MemberGateway{ + + + __construct(con : Connexion) + + insert(idTeam:int, userId:int, role:string): void + + getMembersOfTeam(teamId:int): array + + remove(idTeam:int, idMember:int): void + + isCoach(email:string, idTeam:int): bool + + isMemberOfTeam(idTeam:int, idCurrentUser:int): bool +} + +MemberGateway *--"- con" Connexion + +class AccountGateway{ + + __construct(con : Connexion) + + insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): int + + getRowsFromMail(email:string): ?array + + getHash(email:string): ?string + + exists(email:string): bool + + getAccountFromMail(email:string): ?Account + + getAccountFromToken(token:string): ?Account +} + +AccountGateway *--"- con" Connexion class TeamModel{ - --- + + __construct(gateway : TeamGateway) + createTeam(name : string,picture : string, mainColorValue : int, secondColorValue : int, errors : array) + + addMember(mail:string, teamId:int, role:string): int + listByName(name : string ,errors : array) : ?array - + displayTeam(id : int): Team + + getTeam(idTeam:int, idCurrentUser:int): ?Team + + deleteMember(idMember:int, teamId:int): int + + deleteTeam(email:string, idTeam:int): int + + isCoach(idTeam:int, email:string): bool + + editTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string) + + getAll(user:int): array } -TeamModel *--"- gateway" TeamGateway -TeamModel ..> Team -TeamModel ..> Color +TeamModel *--"- members" MemberGateway +TeamModel *--"- teams" TeamGateway +TeamModel *--"- teams" AccountGateway + 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 + + __construct( model : TeamModel) + + displayCreateTeam(session:SessionHandle): ViewHttpResponse + + displayDeleteMember(session:SessionHandle): ViewHttpResponse + + submitTeam(request:array, session:SessionHandle): HttpResponse + + displayListTeamByName(session:SessionHandle): ViewHttpResponse + + listTeamByName(request:array, session:SessionHandle): HttpResponse + + deleteTeamById(id:int, session:SessionHandle): HttpResponse + + displayTeam(id:int, session:SessionHandle): ViewHttpResponse + + displayAddMember(idTeam:int, session:SessionHandle): ViewHttpResponse + + addMember(idTeam:int, request:array, session:SessionHandle): HttpResponse + + deleteMember(idTeam:int, idMember:int, session:SessionHandle): HttpResponse + + displayEditTeam(idTeam:int, session:SessionHandle): ViewHttpResponse + + editTeam(idTeam:int, request:array, session:SessionHandle): HttpResponse } TeamController *--"- model" TeamModel + + + + class Connexion { } @enduml \ No newline at end of file diff --git a/Documentation/validation.puml b/Documentation/validation.puml index f509cf4..4595a5a 100644 --- a/Documentation/validation.puml +++ b/Documentation/validation.puml @@ -1,18 +1,20 @@ @startuml abstract class Validator { - + validate(name: string, val: mixed): array + + validate(name: string, val: mixed): array {abstract} + then(other: Validator): Validator } class ComposedValidator extends Validator { - - first: Validator - - then: Validator + __construct(first: Validator, then: Validator) - validate(name: string, val: mixed): array + + validate(name: string, val: mixed): array } +ComposedValidator -->"- first" Validator +ComposedValidator -->"- then" Validator + + class SimpleFunctionValidator extends Validator { - predicate: callable - error_factory: callable @@ -28,9 +30,9 @@ class ValidationFail implements JsonSerialize { + __construct(kind: string, message: string) + getMessage(): string + getKind(): string - + jsonSerialize() - + notFound(message: string): ValidationFail + + unauthorized(message:string): ValidationFail + + error(message:string): ValidationFail } class FieldValidationFail extends ValidationFail { @@ -49,13 +51,25 @@ class Validation { + validate(val: mixed, valName: string, failures: &array, validators: Validator...): bool } +Validation ..> Validator + class Validators { - --- + nonEmpty(): Validator + shorterThan(limit: int): Validator + userString(maxLen: int): Validator - ... + + regex(regex:string, msg:string): Validator + + hex(msg:string): Validator + + name(msg:string): Validator + + nameWithSpaces(): Validator + + lenBetween(min:int, max:int): Validator + + email(msg:string): Validator + + isInteger(): Validator + + isIntInRange(min:int, max:int): Validator + + isURL(): Validator } +Validators ..> Validator + + @enduml \ No newline at end of file diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php index 7057e7f..4953fb2 100644 --- a/src/Core/Model/TacticModel.php +++ b/src/Core/Model/TacticModel.php @@ -11,7 +11,6 @@ use IQBall\Core\Validation\ValidationFail; class TacticModel { public const TACTIC_DEFAULT_NAME = "Nouvelle tactique"; - private TacticInfoGateway $tactics; /** @@ -52,13 +51,6 @@ class TacticModel { return $this->tactics->get($id); } - /** - * Return the nb last tactics created - * - * @param integer $nb - * @return array> - */ - /** * Return the nb last tactics * From f71835352d718daa12c2811cc23c3b1ce7150e5b Mon Sep 17 00:00:00 2001 From: "mael.daim" Date: Fri, 12 Jan 2024 13:45:54 +0100 Subject: [PATCH 04/44] updated editor's mvc conception --- Documentation/Conception.md | 2 +- Documentation/mvc/editor.puml | 6 +++++ Documentation/validation.puml | 4 ++-- src/Api/Controller/APIAuthController.php | 6 ++--- src/Api/Controller/APITacticController.php | 4 ++-- src/App/Control.php | 4 ++-- src/App/Controller/AuthController.php | 10 ++++----- src/App/Controller/TeamController.php | 22 +++++++++---------- .../{Validators.php => DefaultValidators.php} | 2 +- 9 files changed, 33 insertions(+), 27 deletions(-) rename src/Core/Validation/{Validators.php => DefaultValidators.php} (99%) diff --git a/Documentation/Conception.md b/Documentation/Conception.md index 68b4cd9..177be45 100644 --- a/Documentation/Conception.md +++ b/Documentation/Conception.md @@ -64,7 +64,7 @@ et de reporter le plus d'erreurs possibles lorsqu'une requête ne valide pas le public function doPostAction(array $form): HttpResponse { $failures = []; $req = HttpRequest::from($form, $failures, [ - 'email' => [Validators::email(), Validators::isLenBetween(6, 64)] + 'email' => [DefaultValidators::email(), DefaultValidators::isLenBetween(6, 64)] ]); if (!empty($failures)) { //ou $req == null diff --git a/Documentation/mvc/editor.puml b/Documentation/mvc/editor.puml index d684e5b..3e68d33 100644 --- a/Documentation/mvc/editor.puml +++ b/Documentation/mvc/editor.puml @@ -35,4 +35,10 @@ class TacticInfoGateway{ TacticInfoGateway *--"- con" Connexion +class TacticValidator{ + + validateAccess(tacticId:int, tactic:?TacticInfo, ownerId:int): ?ValidationFail {static} +} + +EditorController ..> TacticValidator + @enduml \ No newline at end of file diff --git a/Documentation/validation.puml b/Documentation/validation.puml index 4595a5a..64ac1a5 100644 --- a/Documentation/validation.puml +++ b/Documentation/validation.puml @@ -53,7 +53,7 @@ class Validation { Validation ..> Validator -class Validators { +class DefaultValidators { + nonEmpty(): Validator + shorterThan(limit: int): Validator + userString(maxLen: int): Validator @@ -68,7 +68,7 @@ class Validators { + isURL(): Validator } -Validators ..> Validator +DefaultValidators ..> Validator diff --git a/src/Api/Controller/APIAuthController.php b/src/Api/Controller/APIAuthController.php index fc0eef6..8e6291c 100644 --- a/src/Api/Controller/APIAuthController.php +++ b/src/Api/Controller/APIAuthController.php @@ -8,7 +8,7 @@ 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; +use IQBall\Core\Validation\DefaultValidators; class APIAuthController { private AuthModel $model; @@ -27,8 +27,8 @@ class APIAuthController { */ public function authorize(): HttpResponse { return Control::runChecked([ - "email" => [Validators::email(), Validators::lenBetween(5, 256)], - "password" => [Validators::lenBetween(6, 256)], + "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], + "password" => [DefaultValidators::lenBetween(6, 256)], ], function (HttpRequest $req) { $failures = []; $account = $this->model->login($req["email"], $req["password"], $failures); diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index a116add..2156538 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -10,7 +10,7 @@ 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; +use IQBall\Core\Validation\DefaultValidators; /** * API endpoint related to tactics @@ -33,7 +33,7 @@ class APITacticController { */ public function updateName(int $tactic_id, Account $account): HttpResponse { return Control::runChecked([ - "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()], + "name" => [DefaultValidators::lenBetween(1, 50), DefaultValidators::nameWithSpaces()], ], function (HttpRequest $request) use ($tactic_id, $account) { $failures = $this->model->updateName($tactic_id, $request["name"], $account->getUser()->getId()); diff --git a/src/App/Control.php b/src/App/Control.php index 5c2fe0f..b8148bb 100644 --- a/src/App/Control.php +++ b/src/App/Control.php @@ -11,7 +11,7 @@ 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 array $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. * THe callback must accept an HttpRequest, and return an HttpResponse object. * @return HttpResponse @@ -30,7 +30,7 @@ class Control { /** * 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 array $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. * THe callback must accept an HttpRequest, and return an HttpResponse object. * @return HttpResponse diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php index cd89d11..4d9eebe 100644 --- a/src/App/Controller/AuthController.php +++ b/src/App/Controller/AuthController.php @@ -7,7 +7,7 @@ use IQBall\App\ViewHttpResponse; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Model\AuthModel; -use IQBall\Core\Validation\Validators; +use IQBall\Core\Validation\DefaultValidators; class AuthController { private AuthModel $model; @@ -32,10 +32,10 @@ class AuthController { 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)], + "username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)], + "password" => [DefaultValidators::lenBetween(6, 256)], + "confirmpassword" => [DefaultValidators::lenBetween(6, 256)], + "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], ]); if (!empty($fails)) { return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]); diff --git a/src/App/Controller/TeamController.php b/src/App/Controller/TeamController.php index 4ab3fd7..048d182 100644 --- a/src/App/Controller/TeamController.php +++ b/src/App/Controller/TeamController.php @@ -11,7 +11,7 @@ use IQBall\Core\Http\HttpResponse; use IQBall\Core\Model\TeamModel; use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\ValidationFail; -use IQBall\Core\Validation\Validators; +use IQBall\Core\Validation\DefaultValidators; class TeamController { private TeamModel $model; @@ -48,10 +48,10 @@ class TeamController { 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()], + "name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()], + "main_color" => [DefaultValidators::hexColor()], + "second_color" => [DefaultValidators::hexColor()], + "picture" => [DefaultValidators::isURL()], ]); if (!empty($failures)) { $badFields = []; @@ -84,7 +84,7 @@ class TeamController { public function listTeamByName(array $request, SessionHandle $session): HttpResponse { $errors = []; $request = HttpRequest::from($request, $errors, [ - "name" => [Validators::lenBetween(1, 32), Validators::nameWithSpaces()], + "name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()], ]); if (!empty($errors) && $errors[0] instanceof FieldValidationFail) { @@ -166,7 +166,7 @@ class TeamController { ], HttpCodes::FORBIDDEN); } $request = HttpRequest::from($request, $errors, [ - "email" => [Validators::email(), Validators::lenBetween(5, 256)], + "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], ]); if(!empty($errors)) { return ViewHttpResponse::twig('add_member.html.twig', ['badEmail' => true,'idTeam' => $idTeam]); @@ -226,10 +226,10 @@ class TeamController { } $failures = []; $request = HttpRequest::from($request, $failures, [ - "name" => [Validators::lenBetween(1, 32), Validators::nameWithSpaces()], - "main_color" => [Validators::hexColor()], - "second_color" => [Validators::hexColor()], - "picture" => [Validators::isURL()], + "name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()], + "main_color" => [DefaultValidators::hexColor()], + "second_color" => [DefaultValidators::hexColor()], + "picture" => [DefaultValidators::isURL()], ]); if (!empty($failures)) { $badFields = []; diff --git a/src/Core/Validation/Validators.php b/src/Core/Validation/DefaultValidators.php similarity index 99% rename from src/Core/Validation/Validators.php rename to src/Core/Validation/DefaultValidators.php index 52bc08c..b6ffc38 100644 --- a/src/Core/Validation/Validators.php +++ b/src/Core/Validation/DefaultValidators.php @@ -5,7 +5,7 @@ namespace IQBall\Core\Validation; /** * A collection of standard validators */ -class Validators { +class DefaultValidators { /** * @return Validator a validator that validates a given regex */ From 9f8a5f5fc42d7df86267e3dfa8b783c9e76b60cd Mon Sep 17 00:00:00 2001 From: samuel Date: Tue, 9 Jan 2024 17:13:38 +0100 Subject: [PATCH 05/44] email and username persisted and RGPD checkbox --- src/App/Controller/AuthController.php | 10 +++++++--- src/App/Views/display_register.html.twig | 13 ++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php index 7df241d..fced1d3 100644 --- a/src/App/Controller/AuthController.php +++ b/src/App/Controller/AuthController.php @@ -31,20 +31,24 @@ class AuthController { */ public function register(array $request, MutableSessionHandle $session): HttpResponse { $fails = []; - $request = HttpRequest::from($request, $fails, [ + $goodfield = []; + 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]); + if (!(in_array($request['username'], $fails)) or !(in_array($request['email'], $fails))) { + return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails,'username' => $request['username'],'email' => $request['email']]); + } } + $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(); diff --git a/src/App/Views/display_register.html.twig b/src/App/Views/display_register.html.twig index 38bdb43..7b883a5 100644 --- a/src/App/Views/display_register.html.twig +++ b/src/App/Views/display_register.html.twig @@ -62,6 +62,10 @@ text-align: right; } + .consentement{ + font-size: small; + } + #buttons{ display: flex; justify-content: center; @@ -95,15 +99,18 @@ {% endfor %} - + - +

+
Vous avez déjà un compte ? -
From 6cd52d19358efffca2e34f6e58233dc8bddafdbd Mon Sep 17 00:00:00 2001 From: samuel Date: Mon, 15 Jan 2024 10:47:45 +0100 Subject: [PATCH 06/44] PR OK --- src/App/Controller/AuthController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php index fced1d3..dbb72d6 100644 --- a/src/App/Controller/AuthController.php +++ b/src/App/Controller/AuthController.php @@ -31,7 +31,6 @@ class AuthController { */ public function register(array $request, MutableSessionHandle $session): HttpResponse { $fails = []; - $goodfield = []; HttpRequest::from($request, $fails, [ "username" => [Validators::name(), Validators::lenBetween(2, 32)], "password" => [Validators::lenBetween(6, 256)], From 88e0687cd267e4177a6b640acc4472169f82d350 Mon Sep 17 00:00:00 2001 From: "mael.daim" Date: Mon, 15 Jan 2024 16:20:29 +0100 Subject: [PATCH 07/44] updated the of the documentation + added a general markdown with a description for each diagram --- Documentation/Description.md | 82 +++++++++++++++++++++++++++ Documentation/architecture.puml | 60 ++++++++++++++++++++ Documentation/assets/architecture.svg | 1 + Documentation/assets/auth.svg | 1 + Documentation/assets/editor.svg | 1 + Documentation/assets/http.svg | 1 + Documentation/assets/models.svg | 1 + Documentation/assets/session.svg | 1 + Documentation/assets/team.svg | 1 + Documentation/assets/validation.svg | 1 + Documentation/http.puml | 32 ++++++++--- Documentation/session.puml | 27 +++++++++ Documentation/validation.puml | 6 ++ 13 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 Documentation/Description.md create mode 100644 Documentation/architecture.puml create mode 100644 Documentation/assets/architecture.svg create mode 100644 Documentation/assets/auth.svg create mode 100644 Documentation/assets/editor.svg create mode 100644 Documentation/assets/http.svg create mode 100644 Documentation/assets/models.svg create mode 100644 Documentation/assets/session.svg create mode 100644 Documentation/assets/team.svg create mode 100644 Documentation/assets/validation.svg create mode 100644 Documentation/session.puml diff --git a/Documentation/Description.md b/Documentation/Description.md new file mode 100644 index 0000000..ddb7dd0 --- /dev/null +++ b/Documentation/Description.md @@ -0,0 +1,82 @@ +# Welcome on the documentation's description + +## Let's get started with the architecture diagram. +![architecture diagram](./assets/architecture.svg) + +As you can see our entire application is build around three main package. +All of them contained in "src" package. +The core represent the main code of the web application. +It contains all the validation protocol, detailed below, the model of the imposed MVC architecture. +It also has a package named "data", it is a package of the structure of all the data we use in our application. +Of course there is package containing all the gateways as its name indicates. It is where we use the connection to our database. +Allowing to operate on it. + +The App now is more about the web application itself. +Having all the controllers of the MVC architecture the use the model, the validation system and the http system in the core. +It also calls the twig's views inside of App. Finally, it uses the package Session. This one replace the $_SESSION we all know in PHP. +Thanks to this we have a way cleaner use of all session's data. +Nevertheless, all the controllers call not only twig views but also react ones. +Those are present in the package "front", dispatched in several other packages. +Such as assets having all the image and stuff, model containing all the data's structure, style centralizing all css file and eventually components the last package used for the editor. + +Finally, we have the package "Api" that allows to share code and bind all the different third-hand application such as the web admin one. + +## Main class diagram. +![Class diagram](./assets/models.svg) + +You can see how our data is structured contained in the package "data" as explained right above. +There is two clear part. +First of all, the Tactic one. +We got a nice class named TacticInfo representing as it says the information about a tactic, nothing to discuss more about. +It associates an attribute of type "CourtType". This last is just an "evoluated" type of enum with some more features. +We had to do it this way because of the language PHP that doesn't implement such a thing as an enum. + +Now, let's discuss a much bigger part of the diagram. +In this part we find all the team logic. Actually, a team only have an array of members and a "TeamInfo". +The class "TeamInfo" exist to allows to split the data of the members. +The type Team does only link the information about a team and its members. +Talking about them, their class indicate what role they have (either Coach or Player) in the team. +Because a member is registered in the app, therefore he is a user of it. Represented by the type of the same name. +This class does only contain all the user's basic information. +The last class we have is the Account. It could directly be incorporated in User but we decided to split it the same way we did for the team. +Then, Account only has a user and a token which is an identifier. + +## Validation's class diagram +![validation's class diagram](./assets/Validation.svg) + +We implemented our own validation system, here it is! +For the validation methods (for instance those in DefaultValidators) we use lambda to instantiate a Validator. +In general, we use the implementation "SimpleFunctionValidator". +We reconize the strategy pattern. Indeed, we need a family of algorithms because we have many classes that only differ by the way they validate. +Futhermore, you may have notices the ComposedValidator that allows to chain several Validator. +We naturally used the composite pattern to solve this problem. +The other part of the diagram is about the failure a specific field's validation. +We have a concrete class to return a something more general. All the successors are just more precise about the failure. + +## Http's class diagram +![Http's class diagram](./assets/http.svg) +It were we centralize what the app can render, and what the api can receive. +Then, we got the "basic" response (HttpResponse) that just render a HttpCodes. +We have two successors for now. ViewHttpResponse render not only a code but also a view, either react or twig ones. +Finally, we have the JsonHttpResponse that renders, as it's name says, some Json. + +## Session's class diagram +![Session's class diagram](./assets/session.svg) + +It encapsulates the PHP's array "$_SESSION" and kind of replaces it. With two interfaces that dictate how a session should be handled, and same for a mutable one. + +## Model View Controller +All class diagram, separated by their range of action, of the imposed MVC architecture. +All of them have a controller that validates entries with the validation system and check the permission the user has,and whether or not actually do the action. +These controllers are composed by a Model that handle the pure data and is the point of contact between these and the gateways. +Speaking of which, Gateways are composing Models. They use the connection class to access the database and send their query. + +### Team +![team's mvc](./assets/team.svg) + +### Editor +![editor's mvc](./assets/editor.svg) + +### Authentification +![auth's mvc](./assets/auth.svg) + diff --git a/Documentation/architecture.puml b/Documentation/architecture.puml new file mode 100644 index 0000000..3e8f98f --- /dev/null +++ b/Documentation/architecture.puml @@ -0,0 +1,60 @@ +@startuml +'https://plantuml.com/component-diagram + +package front{ + package assets + package components + package model + package style + package views +} + +database sql{ + +} + +package src { + + package "Api"{ + + } + + package "App" { + package Controller + package Session + package Views + } + + package Core{ + package Data + package Gateway + package Http + package Model + package Validation + [Connection] + } + +} + +[sql] -- [Connection] + +[views] -- [style] +[views] -- [components] +[views] -- [assets] +[views] -- [model] + +[Gateway] -- [Connection] + +[Validation] -- [Controller] +[Controller] -- [Session] +[Controller] -- [Http] +[Controller] -- [Views] +[Controller] -- [views] +[Controller] -- [Model] +[Model] -- [Gateway] + +[Api] -- [Validation] +[Api] -- [Model] +[Api] -- [Http] + +@enduml \ No newline at end of file diff --git a/Documentation/assets/architecture.svg b/Documentation/assets/architecture.svg new file mode 100644 index 0000000..a93591b --- /dev/null +++ b/Documentation/assets/architecture.svg @@ -0,0 +1 @@ +frontsrcAppCoreassetscomponentsmodelstyleviewssqlApiControllerSessionViewsDataGatewayHttpModelValidationConnection \ No newline at end of file diff --git a/Documentation/assets/auth.svg b/Documentation/assets/auth.svg new file mode 100644 index 0000000..b25a316 --- /dev/null +++ b/Documentation/assets/auth.svg @@ -0,0 +1 @@ +AuthController__construct (model : AuthModel)displayRegister() : HttpResponseregister(request : array,session : MutableSessionHandle) : HttpResponsedisplayLogin() : HttpResponselogin(request : array , session : MutableSessionHandle) : HttpResponseAuthModel__construct(gateway : AccountGateway)register(username:string, password:string, confirmPassword:string, email:string, &failures:array): ?Account + generateToken() : stringgenerateToken(): stringlogin(email:string, password:string, &failures:array): ?AccountAccountGateway__construct(con : Connexion)insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): intgetRowsFromMail(email:string): ?arraygetHash(email:string): ?stringexists(email:string): boolgetAccountFromMail(email:string): ?AccountgetAccountFromToken(token:string): ?AccountConnexion- model- gateway- con \ No newline at end of file diff --git a/Documentation/assets/editor.svg b/Documentation/assets/editor.svg new file mode 100644 index 0000000..ddc8750 --- /dev/null +++ b/Documentation/assets/editor.svg @@ -0,0 +1 @@ +EditorController__construct (model : TacticModel)openEditorFor(tactic:TacticInfo): ViewHttpResponsecreateNew(): ViewHttpResponseopenTestEditor(courtType:CourtType): ViewHttpResponsecreateNewOfKind(type:CourtType, session:SessionHandle): ViewHttpResponseopenEditor(id:int, session:SessionHandle): ViewHttpResponseTacticModelTACTIC_DEFAULT_NAME:int {frozen}__construct(tactics : TacticInfoGateway)makeNew(name:string, ownerId:int, type:CourtType): TacticInfomakeNewDefault(ownerId:int, type:CourtType): ?TacticInfoget(id:int): ?TacticInfogetLast(nb:int, ownerId:int): arraygetAll(ownerId:int): ?arrayupdateName(id:int, name:string, authId:int): arrayupdateContent(id:int, json:string): ?ValidationFailTacticInfoGateway__construct(con : Connexion)get(id:int): ?TacticInfogetLast(nb:int, ownerId:int): ?arraygetAll(ownerId:int): ?arrayinsert(name:string, owner:int, type:CourtType): intupdateName(id:int, name:string): boolupdateContent(id:int, json:string): boolConnexionTacticValidatorvalidateAccess(tacticId:int, tactic:?TacticInfo, ownerId:int): ?ValidationFail- model- tactics- con \ No newline at end of file diff --git a/Documentation/assets/http.svg b/Documentation/assets/http.svg new file mode 100644 index 0000000..67ddc0a --- /dev/null +++ b/Documentation/assets/http.svg @@ -0,0 +1 @@ +HttpRequestdata: array__construct(data: array) offsetExists(offset: mixed): booloffsetGet(offset: mixed): mixedoffsetSet(offset: mixed, value: mixed)offsetUnset(offset: mixed) from(request: array, fails: &array, schema: array): HttpRequestfromPayload(fails: &array, schema: array): HttpRequestArrayAccessHttpResponsecode: intheaders : array__construct(code: int,headers:array)getCode(): intgetHeaders(): arrayredirect(url:string, code:int): HttpResponsefromCode(code: int): HttpResponseJsonHttpResponsepayload: mixed__construct(payload: mixed, code: int)getJson(): stringViewHttpResponseTWIG_VIEW: int {frozen}REACT_VIEW: int {frozen} file: stringarguments: arraykind: int__construct(kind: int, file: string, arguments: array, code: int)getViewKind(): intgetFile(): stringgetArguments(): array twig(file: string, arguments: array, code: int): ViewHttpResponsereact(file: string, arguments: array, code: int): ViewHttpResponseInto src/AppHttpCodesOK : int {frozen}FOUND : int {frozen}BAD_REQUEST : int {frozen}UNAUTHORIZED : int {frozen}FORBIDDEN : int {frozen}NOT_FOUND : int {frozen} \ No newline at end of file diff --git a/Documentation/assets/models.svg b/Documentation/assets/models.svg new file mode 100644 index 0000000..e691fa7 --- /dev/null +++ b/Documentation/assets/models.svg @@ -0,0 +1 @@ +TacticInfoid: intname: stringcreationDate: stringownerId: stringcontent: string__construct(id:int,name:string,creationDate:int,ownerId:int,courtType:CourtType,content:string)getId(): intgetOwnerId(): intgetCreationTimestamp(): intgetName(): stringgetContent(): stringgetCourtType() : CourtTypeCourtTypevalue : intCOURT_PLAIN : int {frozen}COURT_HALF : int {frozen}__construct(val:int)plain() : CourtTypehalf() : CourtTypename() : stringfromName(name:string) : CourtTypeisPlain() : boolisHalf() : boolBasically an evoluated enumAccounttoken: string__construct(token:string,user:User)getUser() : UsergetToken() : stringUserid : intname : stringemail : stringprofilePicture : string__construct(id : int,name : string,email: string,profilePicture:string)getId() : idgetName() : stringgetEmail() : stringgetProfilePicture() : stringMemberteamId: introle : string__construct(role : string)getUser(): UsergetTeamId(): intgetRole(): stringMember's role is either "Coach" or "Player"TeamInfoname: stringpicture: stringmainColor : stringsecondColor : string__construct(id:int,name:string,picture:string,mainColor:string,secondColor:string)getName(): stringgetPicture(): stringgetMainColor(): stringgetSecondColor(): stringBoth team's color are the hex code of the colorTeam__construct(info:TeamInfo,members: Member[])getInfo(): TeamInfolistMembers(): Member[]- courtType- user- user- info- members * \ No newline at end of file diff --git a/Documentation/assets/session.svg b/Documentation/assets/session.svg new file mode 100644 index 0000000..85ec404 --- /dev/null +++ b/Documentation/assets/session.svg @@ -0,0 +1 @@ +SessionHandlegetInitialTarget(): ?stringgetAccount(): ?AccountMutableSessionHandlesetInitialTarget(url:?string): voidsetAccount(account:Account): voiddestroy(): voidPhpSessionHandleinit(): selfgetAccount(): ?AccountgetInitialTarget(): ?stringsetAccount(account:Account): voidsetInitialTarget(url:?string): voiddestroy(): void \ No newline at end of file diff --git a/Documentation/assets/team.svg b/Documentation/assets/team.svg new file mode 100644 index 0000000..edf8a32 --- /dev/null +++ b/Documentation/assets/team.svg @@ -0,0 +1 @@ +TeamGateway__construct(con : Connexion)insert(name : string ,picture : string, mainColor : Color, secondColor : Color)listByName(name : string): arraygetTeamById(id:int): ?TeamInfogetTeamIdByName(name:string): ?intdeleteTeam(idTeam:int): voideditTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string)getAll(user:int): arrayConnexionMemberGateway__construct(con : Connexion)insert(idTeam:int, userId:int, role:string): voidgetMembersOfTeam(teamId:int): arrayremove(idTeam:int, idMember:int): voidisCoach(email:string, idTeam:int): boolisMemberOfTeam(idTeam:int, idCurrentUser:int): boolAccountGateway__construct(con : Connexion)insertAccount(name:string, email:string, token:string, hash:string, profilePicture:string): intgetRowsFromMail(email:string): ?arraygetHash(email:string): ?stringexists(email:string): boolgetAccountFromMail(email:string): ?AccountgetAccountFromToken(token:string): ?AccountTeamModel__construct(gateway : TeamGateway)createTeam(name : string,picture : string, mainColorValue : int, secondColorValue : int, errors : array)addMember(mail:string, teamId:int, role:string): intlistByName(name : string ,errors : array) : ?arraygetTeam(idTeam:int, idCurrentUser:int): ?TeamdeleteMember(idMember:int, teamId:int): intdeleteTeam(email:string, idTeam:int): intisCoach(idTeam:int, email:string): booleditTeam(idTeam:int, newName:string, newPicture:string, newMainColor:string, newSecondColor:string)getAll(user:int): arrayTeamController__construct( model : TeamModel)displayCreateTeam(session:SessionHandle): ViewHttpResponsedisplayDeleteMember(session:SessionHandle): ViewHttpResponsesubmitTeam(request:array, session:SessionHandle): HttpResponsedisplayListTeamByName(session:SessionHandle): ViewHttpResponselistTeamByName(request:array, session:SessionHandle): HttpResponsedeleteTeamById(id:int, session:SessionHandle): HttpResponsedisplayTeam(id:int, session:SessionHandle): ViewHttpResponsedisplayAddMember(idTeam:int, session:SessionHandle): ViewHttpResponseaddMember(idTeam:int, request:array, session:SessionHandle): HttpResponsedeleteMember(idTeam:int, idMember:int, session:SessionHandle): HttpResponsedisplayEditTeam(idTeam:int, session:SessionHandle): ViewHttpResponseeditTeam(idTeam:int, request:array, session:SessionHandle): HttpResponse- con- con- con- members- teams- teams- model \ No newline at end of file diff --git a/Documentation/assets/validation.svg b/Documentation/assets/validation.svg new file mode 100644 index 0000000..7f19c31 --- /dev/null +++ b/Documentation/assets/validation.svg @@ -0,0 +1 @@ +Validatorvalidate(name: string, val: mixed): arraythen(other: Validator): ValidatorComposedValidator__construct(first: Validator, then: Validator)validate(name: string, val: mixed): arraySimpleFunctionValidatorpredicate: callableerror_factory: callable__construct(predicate: callable, errorsFactory: callable)validate(name: string, val: mixed): arrayValidationFailkind: stringmessage: string__construct(kind: string, message: string)getMessage(): stringgetKind(): stringnotFound(message: string): ValidationFailunauthorized(message:string): ValidationFailerror(message:string): ValidationFailJsonSerializeFieldValidationFailfieldName: string__construct(fieldName: string, message: string)getFieldName(): stringjsonSerialize() invalidChars(fieldName: string): FieldValidationFailempty(fieldName: string): FieldValidationFailmissing(fieldName: string): FieldValidationFailValidation+ validate(val: mixed, valName: string, failures: &array, validators: Validator...): boolDefaultValidatorsnonEmpty(): ValidatorshorterThan(limit: int): ValidatoruserString(maxLen: int): Validatorregex(regex:string, msg:string): Validatorhex(msg:string): Validatorname(msg:string): ValidatornameWithSpaces(): ValidatorlenBetween(min:int, max:int): Validatoremail(msg:string): ValidatorisInteger(): ValidatorisIntInRange(min:int, max:int): ValidatorisURL(): ValidatorFunctionValidatorvalidate_fn: callable__construct(validate_fn:callable)validate(name:string, val:mixed): array- first- then \ No newline at end of file diff --git a/Documentation/http.puml b/Documentation/http.puml index 2f8e22b..e8fd478 100644 --- a/Documentation/http.puml +++ b/Documentation/http.puml @@ -15,37 +15,51 @@ class HttpRequest implements ArrayAccess { class HttpResponse { - code: int - + __construct(code: int) + - headers : array + + __construct(code: int,headers:array) + getCode(): int - - fromCode(code: int): HttpResponse + + getHeaders(): array + + redirect(url:string, code:int): HttpResponse {static} + + fromCode(code: int): HttpResponse {static} } class JsonHttpResponse extends HttpResponse { - payload: mixed - + __construct(payload: mixed, code: int = HttpCodes::OK) + + __construct(payload: mixed, code: int) + getJson(): string } class ViewHttpResponse extends HttpResponse { - + TWIG_VIEW: int {frozen} - + REACT_VIEW: int {frozen} + + TWIG_VIEW: int {frozen} {static} + + REACT_VIEW: int {frozen} {static} - file: string - arguments: array - kind: int - - __construct(kind: int, file: string, arguments: array, code: int = HttpCodes::OK) + - __construct(kind: int, file: string, arguments: array, code: int) + getViewKind(): int + getFile(): string + getArguments(): array - + twig(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse - + react(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse + + twig(file: string, arguments: array, code: int): ViewHttpResponse + + react(file: string, arguments: array, code: int): ViewHttpResponse } note right of ViewHttpResponse Into src/App end note +class HttpCodes{ + + OK : int {static} {frozen} + + FOUND : int {static} {frozen} + + BAD_REQUEST : int {static} {frozen} + + UNAUTHORIZED : int {static} {frozen} + + FORBIDDEN : int {static} {frozen} + + NOT_FOUND : int {static} {frozen} +} + +HttpCodes <.. ViewHttpResponse +HttpCodes <.. HttpResponse + @enduml \ No newline at end of file diff --git a/Documentation/session.puml b/Documentation/session.puml new file mode 100644 index 0000000..ccc8568 --- /dev/null +++ b/Documentation/session.puml @@ -0,0 +1,27 @@ +@startuml + +interface SessionHandle{ + + getInitialTarget(): ?string {abstract} + + getAccount(): ?Account {abstract} +} + +interface MutableSessionHandle{ + + setInitialTarget(url:?string): void + + setAccount(account:Account): void + + destroy(): void +} + +class PhpSessionHandle{ + + init(): self {static} + + getAccount(): ?Account + + getInitialTarget(): ?string + + setAccount(account:Account): void + + setInitialTarget(url:?string): void + + destroy(): void +} + + +PhpSessionHandle ..|> MutableSessionHandle +MutableSessionHandle ..|> SessionHandle + +@enduml \ No newline at end of file diff --git a/Documentation/validation.puml b/Documentation/validation.puml index 64ac1a5..77a8e60 100644 --- a/Documentation/validation.puml +++ b/Documentation/validation.puml @@ -70,6 +70,12 @@ class DefaultValidators { DefaultValidators ..> Validator +class FunctionValidator{ + - validate_fn: callable + + __construct(validate_fn:callable) + + validate(name:string, val:mixed): array +} +Validator <|-- FunctionValidator @enduml \ No newline at end of file From c596bfba719361b5f44c554286dcaf3f8c22b59f Mon Sep 17 00:00:00 2001 From: "mael.daim" Date: Mon, 15 Jan 2024 16:42:07 +0100 Subject: [PATCH 08/44] Fixed bugs --- src/App/Controller/AuthController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php index 386f896..3d16733 100644 --- a/src/App/Controller/AuthController.php +++ b/src/App/Controller/AuthController.php @@ -32,10 +32,10 @@ class AuthController { public function register(array $request, MutableSessionHandle $session): HttpResponse { $fails = []; 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)], + "username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)], + "password" => [DefaultValidators::lenBetween(6, 256)], + "confirmpassword" => [DefaultValidators::lenBetween(6, 256)], + "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], ]); if (!empty($fails)) { From d9862b85c0240b520e5c473368173c021f96315f Mon Sep 17 00:00:00 2001 From: "mael.daim" Date: Tue, 16 Jan 2024 09:54:21 +0100 Subject: [PATCH 09/44] applied required review's changement --- Documentation/Description.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Documentation/Description.md b/Documentation/Description.md index ddb7dd0..fee90c5 100644 --- a/Documentation/Description.md +++ b/Documentation/Description.md @@ -21,7 +21,7 @@ Such as assets having all the image and stuff, model containing all the data's s Finally, we have the package "Api" that allows to share code and bind all the different third-hand application such as the web admin one. -## Main class diagram. +## Main data class diagram. ![Class diagram](./assets/models.svg) You can see how our data is structured contained in the package "data" as explained right above. @@ -33,7 +33,7 @@ We had to do it this way because of the language PHP that doesn't implement such Now, let's discuss a much bigger part of the diagram. In this part we find all the team logic. Actually, a team only have an array of members and a "TeamInfo". -The class "TeamInfo" exist to allows to split the data of the members. +The class "TeamInfo" only exists to split the team's information data (name, id etc) from the members. The type Team does only link the information about a team and its members. Talking about them, their class indicate what role they have (either Coach or Player) in the team. Because a member is registered in the app, therefore he is a user of it. Represented by the type of the same name. @@ -42,14 +42,14 @@ The last class we have is the Account. It could directly be incorporated in User Then, Account only has a user and a token which is an identifier. ## Validation's class diagram -![validation's class diagram](./assets/Validation.svg) +![validation's class diagram](./assets/validation.svg) We implemented our own validation system, here it is! For the validation methods (for instance those in DefaultValidators) we use lambda to instantiate a Validator. In general, we use the implementation "SimpleFunctionValidator". We reconize the strategy pattern. Indeed, we need a family of algorithms because we have many classes that only differ by the way they validate. -Futhermore, you may have notices the ComposedValidator that allows to chain several Validator. -We naturally used the composite pattern to solve this problem. +Futhermore, you may have notices the ComposedValidator that allows to chain several Validator. +We can see that this system uses the composite pattern The other part of the diagram is about the failure a specific field's validation. We have a concrete class to return a something more general. All the successors are just more precise about the failure. @@ -63,8 +63,7 @@ Finally, we have the JsonHttpResponse that renders, as it's name says, some Json ## Session's class diagram ![Session's class diagram](./assets/session.svg) -It encapsulates the PHP's array "$_SESSION" and kind of replaces it. With two interfaces that dictate how a session should be handled, and same for a mutable one. - +It encapsulates the PHP's array "$_SESSION". With two interfaces that dictate how a session should be handled, and same for a mutable one. ## Model View Controller All class diagram, separated by their range of action, of the imposed MVC architecture. All of them have a controller that validates entries with the validation system and check the permission the user has,and whether or not actually do the action. From ae3f14c663a0dcc8c185f3430ece2f36259bb9bf Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Tue, 16 Jan 2024 16:12:07 +0100 Subject: [PATCH 10/44] fix match type in hAPI::handleMatch --- src/Api/API.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/API.php b/src/Api/API.php index da00749..080db59 100644 --- a/src/Api/API.php +++ b/src/Api/API.php @@ -26,12 +26,12 @@ class API { /** - * @param array $match + * @param array|false $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 { + public static function handleMatch($match, callable $tryGetAuthorization): HttpResponse { if (!$match) { return new JsonHttpResponse([ValidationFail::notFound("not found")]); } From 740eb3d474bbf75eca4631d1d92ba8433b71cccf Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Tue, 16 Jan 2024 16:14:55 +0100 Subject: [PATCH 11/44] fix home button in home --- front/views/template/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/views/template/Header.tsx b/front/views/template/Header.tsx index 4bbd538..5c8cbcd 100644 --- a/front/views/template/Header.tsx +++ b/front/views/template/Header.tsx @@ -14,7 +14,7 @@ export function Header({ username }: { username: string }) { id="iqball" className="clickable" onClick={() => { - location.pathname = "/" + location.pathname = BASE + "/" }}> IQ Ball From 3ace79357866a6f5aa31c64da8ece20d17fd3d91 Mon Sep 17 00:00:00 2001 From: maxime Date: Tue, 16 Jan 2024 22:49:25 +0100 Subject: [PATCH 12/44] add eslint --- .eslintrc.js | 21 +++++++++++++++++++++ package.json | 13 ++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 .eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..aa4a8bc --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,21 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: [ + '@typescript-eslint', + 'react', + 'react-hooks' + ], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended' + ], + settings: { + react: { + version: 'detect' + } + } +}; \ No newline at end of file diff --git a/package.json b/package.json index 1380d59..966a0e9 100644 --- a/package.json +++ b/package.json @@ -24,16 +24,15 @@ "format": "prettier --config .prettierrc 'front' --write", "tsc": "tsc" }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] - }, "devDependencies": { "@vitejs/plugin-react": "^4.1.0", "prettier": "^3.1.0", "typescript": "^5.2.2", - "vite-plugin-svgr": "^4.1.0" + "vite-plugin-svgr": "^4.1.0", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.53.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0" } } From 9cde3af510d0f95819cf10373d72402502a6065d Mon Sep 17 00:00:00 2001 From: Override-6 Date: Fri, 8 Dec 2023 17:51:25 +0100 Subject: [PATCH 13/44] add basic api routes to get info on server, users and tactics --- Documentation/database_mld.puml | 2 +- public/api/index.php | 22 ++++++- sql/database.php | 18 ++++++ sql/setup-tables.sql | 15 ++--- src/Api/API.php | 20 ++++--- src/Api/Controller/APIAccountsController.php | 55 +++++++++++++++++ src/Api/Controller/APIAuthController.php | 3 +- src/Api/Controller/APIServerController.php | 45 ++++++++++++++ src/Api/Controller/APITacticController.php | 26 +++++++- src/App/App.php | 9 ++- src/App/Control.php | 21 +++++-- src/Core/Action.php | 26 +++++--- src/Core/Data/User.php | 18 +++++- src/Core/Gateway/AccountGateway.php | 62 ++++++++++++++++++-- src/Core/Gateway/MemberGateway.php | 4 +- src/Core/Gateway/TacticInfoGateway.php | 18 ++++++ src/Core/Model/AuthModel.php | 12 +++- src/Core/Model/TacticModel.php | 12 ++++ src/Core/Validation/DefaultValidators.php | 6 +- 19 files changed, 342 insertions(+), 52 deletions(-) create mode 100644 src/Api/Controller/APIAccountsController.php create mode 100644 src/Api/Controller/APIServerController.php diff --git a/Documentation/database_mld.puml b/Documentation/database_mld.puml index 2c33ce2..170fad7 100644 --- a/Documentation/database_mld.puml +++ b/Documentation/database_mld.puml @@ -7,7 +7,7 @@ object Account { email phoneNumber passwordHash - profilePicture + profile_picture } object TacticFolder { diff --git a/public/api/index.php b/public/api/index.php index da25013..91fa23d 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -6,7 +6,9 @@ require "../../sql/database.php"; require "../../src/index-utils.php"; use IQBall\Api\API; +use IQBall\Api\Controller\APIAccountsController; use IQBall\Api\Controller\APIAuthController; +use IQBall\Api\Controller\APIServerController; use IQBall\Api\Controller\APITacticController; use IQBall\App\Session\PhpSessionHandle; use IQBall\Core\Action; @@ -17,6 +19,8 @@ use IQBall\Core\Gateway\TacticInfoGateway; use IQBall\Core\Model\AuthModel; use IQBall\Core\Model\TacticModel; +$basePath = get_public_path(__DIR__); + function getTacticController(): APITacticController { return new APITacticController(new TacticModel(new TacticInfoGateway(new Connection(get_database())))); } @@ -25,14 +29,30 @@ function getAuthController(): APIAuthController { return new APIAuthController(new AuthModel(new AccountGateway(new Connection(get_database())))); } +function getAccountController(): APIAccountsController { + $con = new Connection(get_database()); + return new APIAccountsController(new AccountGateway($con)); +} + +function getServerController(): APIServerController { + global $basePath; + return new APIServerController($basePath, get_database()); +} + function getRoutes(): AltoRouter { $router = new AltoRouter(); - $router->setBasePath(get_public_path(__DIR__)); + global $basePath; + $router->setBasePath($basePath); $router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize())); $router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); $router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc))); + $router->map("GET", "/admin/list-users", Action::admin(fn() => getAccountController()->listUsers($_GET))); + $router->map("GET", "/admin/user/[i:id]", Action::admin(fn(int $id) => getAccountController()->getUser($id))); + $router->map("GET", "/admin/user/[i:id]/space", Action::admin(fn(int $id) => getTacticController()->getUserTactics($id))); + $router->map("GET", "/admin/server-info", Action::admin(fn() => getServerController()->getServerInfo())); + return $router; } diff --git a/sql/database.php b/sql/database.php index 8f5aa9d..c301fda 100644 --- a/sql/database.php +++ b/sql/database.php @@ -1,5 +1,9 @@ insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT)); + $accounts->setIsAdmin($id, true); + } +} diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 0d157d9..1a756f6 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -11,7 +11,8 @@ CREATE TABLE Account username varchar NOT NULL, token varchar UNIQUE NOT NULL, hash varchar NOT NULL, - profilePicture varchar NOT NULL + profile_picture varchar NOT NULL, + is_admin boolean DEFAULT false NOT NULL ); CREATE TABLE Tactic @@ -25,13 +26,6 @@ CREATE TABLE Tactic FOREIGN KEY (owner) REFERENCES Account ); -CREATE TABLE FormEntries -( - name varchar NOT NULL, - description varchar NOT NULL -); - - CREATE TABLE Team ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -41,11 +35,10 @@ CREATE TABLE Team second_color varchar NOT NULL ); - CREATE TABLE Member ( - id_team integer NOT NULL, - id_user integer NOT NULL, + id_team integer NOT NULL, + id_user integer NOT NULL, role text CHECK (role IN ('COACH', 'PLAYER')) NOT NULL, FOREIGN KEY (id_team) REFERENCES Team (id), FOREIGN KEY (id_user) REFERENCES Account (id) diff --git a/src/Api/API.php b/src/Api/API.php index 080db59..22b9566 100644 --- a/src/Api/API.php +++ b/src/Api/API.php @@ -4,6 +4,8 @@ namespace IQBall\Api; use Exception; use IQBall\Core\Action; +use IQBall\Core\Data\Account; +use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\ValidationFail; @@ -27,7 +29,7 @@ class API { /** * @param array|false $match - * @param callable $tryGetAuthorization function to return an authorisation object for the given action (if required) + * @param callable(): Account $tryGetAuthorization function to return account authorisation for the given action (if required) * @return HttpResponse * @throws Exception */ @@ -41,15 +43,19 @@ class API { throw new Exception("routed action is not an AppAction object."); } - $auth = null; + $account = null; - if ($action->isAuthRequired()) { - $auth = call_user_func($tryGetAuthorization); - if ($auth == null) { - return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")]); + if ($action->getAuthType() != Action::NO_AUTH) { + $account = call_user_func($tryGetAuthorization); + if ($account == null) { + return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")], HttpCodes::UNAUTHORIZED); + } + + if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { + return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); } } - return $action->run($match['params'], $auth); + return $action->run($match['params'], $account); } } diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php new file mode 100644 index 0000000..3fe2de0 --- /dev/null +++ b/src/Api/Controller/APIAccountsController.php @@ -0,0 +1,55 @@ +accounts = $accounts; + } + + + /** + * @param array $request + * @return HttpResponse + */ + public function listUsers(array $request): HttpResponse { + return Control::runCheckedFrom($request, [ + 'start' => [DefaultValidators::isUnsignedInteger()], + 'n' => [DefaultValidators::isUnsignedInteger()], + ], function (HttpRequest $req) { + $accounts = $this->accounts->listAccounts(intval($req['start']), intval($req['n'])); + $response = array_map(fn(Account $acc) => $acc->getUser(), $accounts); + return new JsonHttpResponse($response); + }, true); + } + + + /** + * @param int $userId + * @return HttpResponse given user information. + */ + public function getUser(int $userId): HttpResponse { + $acc = $this->accounts->getAccount($userId); + + if ($acc == null) { + return new JsonHttpResponse([ValidationFail::notFound("User not found")], HttpCodes::NOT_FOUND); + } + + return new JsonHttpResponse($acc->getUser()); + } +} diff --git a/src/Api/Controller/APIAuthController.php b/src/Api/Controller/APIAuthController.php index 8e6291c..e257844 100644 --- a/src/Api/Controller/APIAuthController.php +++ b/src/Api/Controller/APIAuthController.php @@ -38,7 +38,6 @@ class APIAuthController { } return new JsonHttpResponse(["authorization" => $account->getToken()]); - }); + }, true); } - } diff --git a/src/Api/Controller/APIServerController.php b/src/Api/Controller/APIServerController.php new file mode 100644 index 0000000..1c82d3e --- /dev/null +++ b/src/Api/Controller/APIServerController.php @@ -0,0 +1,45 @@ +basePath = $basePath; + $this->pdo = $pdo; + } + + private function countLines(string $table): int { + $stmnt = $this->pdo->prepare("SELECT count(*) FROM $table"); + $stmnt->execute(); + $res = $stmnt->fetch(\PDO::FETCH_BOTH); + return $res[0]; + } + + + /** + * @return HttpResponse some (useless) information about the server + */ + public function getServerInfo(): HttpResponse { + + return new JsonHttpResponse([ + 'base_path' => $this->basePath, + 'date' => (int) gettimeofday(true) * 1000, + 'database' => [ + 'accounts' => $this->countLines("Account") . " line(s)", + 'tactics' => $this->countLines("Tactic") . " line(s)", + 'teams' => $this->countLines("Team") . " line(s)", + ], + ]); + } + +} diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index 2156538..0d99f41 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -4,12 +4,12 @@ namespace IQBall\Api\Controller; use IQBall\App\Control; use IQBall\Core\Data\Account; +use IQBall\Core\Data\TacticInfo; use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Model\TacticModel; -use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\DefaultValidators; /** @@ -44,7 +44,7 @@ class APITacticController { } return HttpResponse::fromCode(HttpCodes::OK); - }); + }, true); } /** @@ -60,6 +60,26 @@ class APITacticController { return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); } return HttpResponse::fromCode(HttpCodes::OK); - }); + }, true); } + + + /** + * @param int $userId + * @return HttpResponse given user information. + */ + public function getUserTactics(int $userId): HttpResponse { + $tactics = $this->model->listAllOf($userId); + + $response = array_map(fn(TacticInfo $t) => [ + 'id' => $t->getId(), + 'name' => $t->getName(), + 'court' => $t->getCourtType(), + 'creation_date' => $t->getCreationDate(), + ], $tactics); + + return new JsonHttpResponse($response); + } + + } diff --git a/src/App/App.php b/src/App/App.php index cd3c293..cba59de 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -4,8 +4,10 @@ namespace IQBall\App; use IQBall\App\Session\MutableSessionHandle; use IQBall\Core\Action; +use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; +use IQBall\Core\Validation\ValidationFail; use Twig\Environment; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -77,13 +79,18 @@ class App { * @return HttpResponse */ public static function runAction(string $authRoute, Action $action, array $params, MutableSessionHandle $session): HttpResponse { - if ($action->isAuthRequired()) { + if ($action->getAuthType() != Action::NO_AUTH) { $account = $session->getAccount(); if ($account == null) { // put in the session the initial url the user wanted to get $session->setInitialTarget($_SERVER['REQUEST_URI']); return HttpResponse::redirect($authRoute); } + + if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { + return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); + } + } return $action->run($params, $session); diff --git a/src/App/Control.php b/src/App/Control.php index b8148bb..0e517e4 100644 --- a/src/App/Control.php +++ b/src/App/Control.php @@ -5,6 +5,7 @@ namespace IQBall\App; use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; +use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\ValidationFail; use IQBall\Core\Validation\Validator; @@ -13,18 +14,24 @@ class Control { * Runs given callback, if the request's json validates the given schema. * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * THe callback must accept an HttpRequest, and return an HttpResponse object. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @param bool $errorInJson if set to true, the returned response will be a JsonHttpResponse if the schema isn't validated * @return HttpResponse */ - public static function runChecked(array $schema, callable $run): HttpResponse { + public static function runChecked(array $schema, callable $run, bool $errorInJson = false): 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]); + } + return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST); } $payload = get_object_vars($payload_obj); - return self::runCheckedFrom($payload, $schema, $run); + return self::runCheckedFrom($payload, $schema, $run, $errorInJson); } /** @@ -32,14 +39,18 @@ class Control { * @param array $data the request's data array. * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * THe callback must accept an HttpRequest, and return an HttpResponse object. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @param bool $errorInJson if set to true, the returned response will be a JsonHttpResponse if the schema isn't validated * @return HttpResponse */ - public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { + public static function runCheckedFrom(array $data, array $schema, callable $run, bool $errorInJson = false): HttpResponse { $fails = []; $request = HttpRequest::from($data, $fails, $schema); if (!empty($fails)) { + if ($errorInJson) { + return new JsonHttpResponse($fails); + } return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST); } diff --git a/src/Core/Action.php b/src/Core/Action.php index 35721c1..df40ea9 100644 --- a/src/Core/Action.php +++ b/src/Core/Action.php @@ -9,23 +9,27 @@ use IQBall\Core\Http\HttpResponse; * @template S session */ class Action { + public const NO_AUTH = 1; + public const AUTH_USER = 2; + public const AUTH_ADMIN = 3; + /** * @var callable(mixed[], S): HttpResponse $action action to call */ protected $action; - private bool $isAuthRequired; + private int $authType; /** * @param callable(mixed[], S): HttpResponse $action */ - protected function __construct(callable $action, bool $isAuthRequired) { + protected function __construct(callable $action, int $authType) { $this->action = $action; - $this->isAuthRequired = $isAuthRequired; + $this->authType = $authType; } - public function isAuthRequired(): bool { - return $this->isAuthRequired; + public function getAuthType(): int { + return $this->authType; } /** @@ -45,7 +49,7 @@ class Action { * @return Action an action that does not require to have an authorization. */ public static function noAuth(callable $action): Action { - return new Action($action, false); + return new Action($action, self::NO_AUTH); } /** @@ -53,6 +57,14 @@ class Action { * @return Action an action that does require to have an authorization. */ public static function auth(callable $action): Action { - return new Action($action, true); + return new Action($action, self::AUTH_USER); + } + + /** + * @param callable(mixed[], S): HttpResponse $action + * @return Action an action that does require to have an authorization, and to be an administrator. + */ + public static function admin(callable $action): Action { + return new Action($action, self::AUTH_ADMIN); } } diff --git a/src/Core/Data/User.php b/src/Core/Data/User.php index 71e0dd1..02a44c0 100644 --- a/src/Core/Data/User.php +++ b/src/Core/Data/User.php @@ -2,8 +2,6 @@ namespace IQBall\Core\Data; -use _PHPStan_4c4f22f13\Nette\Utils\Json; - class User implements \JsonSerializable { /** * @var string $email user's mail address @@ -25,17 +23,31 @@ class User implements \JsonSerializable { */ private string $profilePicture; + /** + * @var bool isAdmin + */ + private bool $isAdmin; + /** * @param string $email * @param string $name * @param int $id * @param string $profilePicture + * @param bool $isAdmin */ - public function __construct(string $email, string $name, int $id, string $profilePicture) { + public function __construct(string $email, string $name, int $id, string $profilePicture, bool $isAdmin) { $this->email = $email; $this->name = $name; $this->id = $id; $this->profilePicture = $profilePicture; + $this->isAdmin = $isAdmin; + } + + /** + * @return bool + */ + public function isAdmin(): bool { + return $this->isAdmin; } /** diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index a9c3e18..6251b85 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -2,6 +2,7 @@ namespace IQBall\Core\Gateway; +use Cassandra\PreparedStatement; use IQBall\Core\Connection; use IQBall\Core\Data\Account; use IQBall\Core\Data\User; @@ -18,7 +19,7 @@ class AccountGateway { } public function insertAccount(string $name, string $email, string $token, string $hash, string $profilePicture): int { - $this->con->exec("INSERT INTO Account(username, hash, email, token,profilePicture) VALUES (:username,:hash,:email,:token,:profilePic)", [ + $this->con->exec("INSERT INTO Account(username, hash, email, token,profile_picture) VALUES (:username,:hash,:email,:token,:profilePic)", [ ':username' => [$name, PDO::PARAM_STR], ':hash' => [$hash, PDO::PARAM_STR], ':email' => [$email, PDO::PARAM_STR], @@ -28,6 +29,22 @@ class AccountGateway { return intval($this->con->lastInsertId()); } + + /** + * promote or demote a user to server administrator + * @param int $id + * @param bool $isAdmin true to promote, false to demote + * @return bool true if the given user exists + */ + public function setIsAdmin(int $id, bool $isAdmin): bool { + $stmnt = $this->con->prepare("UPDATE Account SET is_admin = :is_admin WHERE id = :id"); + $stmnt->bindValue(':is_admin', $isAdmin); + $stmnt->bindValue(':id', $id); + $stmnt->execute(); + + return $stmnt->rowCount() > 0; + } + /** * @param string $email * @return array|null @@ -66,7 +83,7 @@ class AccountGateway { return null; } - return new Account($acc["token"], new User($email, $acc["username"], $acc["id"], $acc["profilePicture"])); + return new Account($acc["token"], new User($email, $acc["username"], $acc["id"], $acc["profile_picture"], $acc['is_admin'])); } /** @@ -74,13 +91,48 @@ class AccountGateway { * @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)) { + $stmnt = $this->con->prepare("SELECT * FROM Account WHERE token = :token"); + $stmnt->bindValue(':token', $token); + return $this->getAccountFrom($stmnt); + } + + /** + * @param int $id get an account from given identifier + * @return Account|null + */ + public function getAccount(int $id): ?Account { + $stmnt = $this->con->prepare("SELECT * FROM Account WHERE id = :id"); + $stmnt->bindValue(':id', $id); + return $this->getAccountFrom($stmnt); + } + + private function getAccountFrom(\PDOStatement $stmnt): ?Account { + $stmnt->execute(); + $acc = $stmnt->fetch(PDO::FETCH_ASSOC); + + if ($acc == null) { return null; } - return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profilePicture"])); + return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $acc["is_admin"])); } + /** + * Return a list containing n accounts from a given starting index + * + * @param integer $n the number of accounts to retrieve + * @param int $start starting index of the list content + * @return Account[] + */ + public function listAccounts(int $start, int $n): ?array { + $res = $this->con->fetch( + "SELECT * FROM Account ORDER BY email LIMIT :offset, :n", + [ + ":offset" => [$start, PDO::PARAM_INT], + ":n" => [$n, PDO::PARAM_INT], + ] + ); + return array_map(fn(array $acc) => new Account($acc["email"], new User($acc["username"], $acc["token"], $acc["id"], $acc["profile_picture"], $acc["is_admin"])), $res); + } } diff --git a/src/Core/Gateway/MemberGateway.php b/src/Core/Gateway/MemberGateway.php index a5116e8..98d2d41 100644 --- a/src/Core/Gateway/MemberGateway.php +++ b/src/Core/Gateway/MemberGateway.php @@ -41,12 +41,12 @@ class MemberGateway { */ public function getMembersOfTeam(int $teamId): array { $rows = $this->con->fetch( - "SELECT a.id,a.email,a.username,a.profilePicture,m.role FROM Account a,team t,Member m WHERE t.id = :id AND m.id_team = t.id AND m.id_user = a.id", + "SELECT a.id,a.email,a.username,a.profile_picture,m.role FROM Account a,team t,Member m WHERE t.id = :id AND m.id_team = t.id AND m.id_user = a.id", [ ":id" => [$teamId, PDO::PARAM_INT], ] ); - return array_map(fn($row) => new Member(new User($row['email'], $row['username'], $row['id'], $row['profilePicture']), $teamId, $row['role']), $rows); + return array_map(fn($row) => new Member(new User($row['email'], $row['username'], $row['id'], $row['profile_picture'], $row['is_admin']), $teamId, $row['role']), $rows); } /** diff --git a/src/Core/Gateway/TacticInfoGateway.php b/src/Core/Gateway/TacticInfoGateway.php index 08302c9..d4b81e0 100644 --- a/src/Core/Gateway/TacticInfoGateway.php +++ b/src/Core/Gateway/TacticInfoGateway.php @@ -83,6 +83,24 @@ class TacticInfoGateway { return $res; } + + /** + * Return a list containing the nth last tactics of a given user id + * + * @param integer $user_id + * @return TacticInfo[] + */ + public function listAllOf(int $user_id): array { + $res = $this->con->fetch( + "SELECT * FROM Tactic WHERE owner = :owner_id ORDER BY creation_date DESC", + [ + ":owner_id" => [$user_id, PDO::PARAM_STR], + ] + ); + return array_map(fn(array $t) => new TacticInfo($t['id'], $t["name"], strtotime($t["creation_date"]), $t["owner"], CourtType::fromName($t['court_type']), $t['content']), $res); + } + + /** * @param string $name * @param int $owner diff --git a/src/Core/Model/AuthModel.php b/src/Core/Model/AuthModel.php index bc29248..b937924 100644 --- a/src/Core/Model/AuthModel.php +++ b/src/Core/Model/AuthModel.php @@ -29,7 +29,13 @@ class AuthModel { * @return Account|null the registered account or null if failures occurred * @throws Exception */ - public function register(string $username, string $password, string $confirmPassword, string $email, array &$failures): ?Account { + public function register( + string $username, + string $password, + string $confirmPassword, + string $email, + array &$failures + ): ?Account { if ($password != $confirmPassword) { $failures[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes."); @@ -47,7 +53,7 @@ class AuthModel { $token = $this->generateToken(); $accountId = $this->gateway->insertAccount($username, $email, $token, $hash, self::DEFAULT_PROFILE_PICTURE); - return new Account($token, new User($email, $username, $accountId, self::DEFAULT_PROFILE_PICTURE)); + return new Account($token, new User($email, $username, $accountId, self::DEFAULT_PROFILE_PICTURE, false)); } /** @@ -55,7 +61,7 @@ class AuthModel { * @return string * @throws Exception */ - private function generateToken(): string { + public static function generateToken(): string { return base64_encode(random_bytes(64)); } diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php index 4953fb2..590a106 100644 --- a/src/Core/Model/TacticModel.php +++ b/src/Core/Model/TacticModel.php @@ -62,6 +62,18 @@ class TacticModel { return $this->tactics->getLast($nb, $ownerId); } + + /** + * Return a list containing all the tactics of a given user + * NOTE: if given user id does not match any user, this function returns an empty array + * + * @param integer $user_id + * @return TacticInfo[] | null + */ + public function listAllOf(int $user_id): ?array { + return$this->tactics->listAllOf($user_id); + } + /** * Get all the tactics of the owner * diff --git a/src/Core/Validation/DefaultValidators.php b/src/Core/Validation/DefaultValidators.php index b6ffc38..67d5da2 100644 --- a/src/Core/Validation/DefaultValidators.php +++ b/src/Core/Validation/DefaultValidators.php @@ -68,7 +68,11 @@ class DefaultValidators { public static function isInteger(): Validator { - return self::regex("/^[0-9]+$/"); + return self::regex("/^-[0-9]+$/", "field is not an integer"); + } + + public static function isUnsignedInteger(): Validator { + return self::regex("/^[0-9]+$/", "field is not an unsigned integer"); } public static function isIntInRange(int $min, int $max): Validator { From 5df30ee415146894990fcc0444a3335503f2b0dc Mon Sep 17 00:00:00 2001 From: Override-6 Date: Fri, 8 Dec 2023 22:54:34 +0100 Subject: [PATCH 14/44] add remove, update and add new accounts --- public/api/index.php | 16 ++++-- sql/database.php | 2 +- src/Api/API.php | 6 +- src/Api/Controller/APIAccountsController.php | 59 ++++++++++++++++++-- src/Api/Controller/APIAuthController.php | 2 +- src/App/Control.php | 4 +- src/App/Controller/AuthController.php | 23 ++++++-- src/Core/Gateway/AccountGateway.php | 39 +++++++++++-- src/Core/Http/HttpResponse.php | 4 +- src/Core/Model/AuthModel.php | 33 +++++------ src/Core/Validation/DefaultValidators.php | 57 +++++++++++++++++++ 11 files changed, 198 insertions(+), 47 deletions(-) diff --git a/public/api/index.php b/public/api/index.php index 91fa23d..226e8f1 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -31,7 +31,8 @@ function getAuthController(): APIAuthController { function getAccountController(): APIAccountsController { $con = new Connection(get_database()); - return new APIAccountsController(new AccountGateway($con)); + $gw = new AccountGateway($con); + return new APIAccountsController(new AuthModel($gw), $gw); } function getServerController(): APIServerController { @@ -48,10 +49,13 @@ function getRoutes(): AltoRouter { $router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); $router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc))); - $router->map("GET", "/admin/list-users", Action::admin(fn() => getAccountController()->listUsers($_GET))); - $router->map("GET", "/admin/user/[i:id]", Action::admin(fn(int $id) => getAccountController()->getUser($id))); - $router->map("GET", "/admin/user/[i:id]/space", Action::admin(fn(int $id) => getTacticController()->getUserTactics($id))); - $router->map("GET", "/admin/server-info", Action::admin(fn() => getServerController()->getServerInfo())); + $router->map("GET", "/admin/list-users", Action::noAuth(fn() => getAccountController()->listUsers($_GET))); + $router->map("GET", "/admin/user/[i:id]", Action::noAuth(fn(int $id) => getAccountController()->getUser($id))); + $router->map("GET", "/admin/user/[i:id]/space", Action::noAuth(fn(int $id) => getTacticController()->getUserTactics($id))); + $router->map("POST", "/admin/user/add", Action::noAuth(fn() => getAccountController()->addUser())); + $router->map("POST", "/admin/user/remove-all", Action::noAuth(fn() => getAccountController()->removeUsers())); + $router->map("POST", "/admin/user/[i:id]/update", Action::noAuth(fn(int $id) => getAccountController()->updateUser($id))); + $router->map("GET", "/admin/server-info", Action::noAuth(fn() => getServerController()->getServerInfo())); return $router; } @@ -76,4 +80,4 @@ function tryGetAuthorization(): ?Account { return $gateway->getAccountFromToken($token); } -Api::render(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization())); +Api::consume(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization())); diff --git a/sql/database.php b/sql/database.php index c301fda..af94ef2 100644 --- a/sql/database.php +++ b/sql/database.php @@ -39,7 +39,7 @@ function init_database(PDO $pdo): void { foreach ($defaultAccounts as $name) { $email = "$name@mail.com"; - $id = $accounts->insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT)); + $id = $accounts->insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT), "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"); $accounts->setIsAdmin($id, true); } } diff --git a/src/Api/API.php b/src/Api/API.php index 22b9566..b955963 100644 --- a/src/Api/API.php +++ b/src/Api/API.php @@ -11,9 +11,13 @@ use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\ValidationFail; class API { - public static function render(HttpResponse $response): void { + public static function consume(HttpResponse $response): void { + error_log("consuming response" . $response->getCode()); http_response_code($response->getCode()); + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Headers: *'); + foreach ($response->getHeaders() as $header => $value) { header("$header: $value"); } diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php index 3fe2de0..3ec62df 100644 --- a/src/Api/Controller/APIAccountsController.php +++ b/src/Api/Controller/APIAccountsController.php @@ -10,16 +10,20 @@ use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\DefaultValidators; +use IQBall\Core\Model\AuthModel; +use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\ValidationFail; class APIAccountsController { private AccountGateway $accounts; + private AuthModel $authModel; /** * @param AccountGateway $accounts */ - public function __construct(AccountGateway $accounts) { + public function __construct(AuthModel $model, AccountGateway $accounts) { $this->accounts = $accounts; + $this->authModel = $model; } @@ -33,12 +37,14 @@ class APIAccountsController { 'n' => [DefaultValidators::isUnsignedInteger()], ], function (HttpRequest $req) { $accounts = $this->accounts->listAccounts(intval($req['start']), intval($req['n'])); - $response = array_map(fn(Account $acc) => $acc->getUser(), $accounts); - return new JsonHttpResponse($response); + $users = array_map(fn(Account $acc) => $acc->getUser(), $accounts); + return new JsonHttpResponse([ + "users" => $users, + "totalCount" => $this->accounts->totalCount(), + ]); }, true); } - /** * @param int $userId * @return HttpResponse given user information. @@ -52,4 +58,49 @@ class APIAccountsController { return new JsonHttpResponse($acc->getUser()); } + + public function addUser(): HttpResponse { + return Control::runChecked([ + "username" => [DefaultValidators::name()], + "email" => [DefaultValidators::email()], + "password" => [DefaultValidators::password()], + "isAdmin" => [DefaultValidators::bool()], + ], function (HttpRequest $req) { + $model = new AuthModel($this->accounts); + + $account = $model->register($req["username"], $req["password"], $req["email"]); + if ($account == null) { + return new JsonHttpResponse([new ValidationFail("already exists", "An account with provided email ")], HttpCodes::FORBIDDEN); + } + + return new JsonHttpResponse([ + "id" => $account->getUser()->getId(), + ]); + }, true); + } + + public function removeUsers(): HttpResponse { + return Control::runChecked([ + "identifiers" => [DefaultValidators::array(), DefaultValidators::forall(DefaultValidators::isUnsignedInteger())], + ], function (HttpRequest $req) { + $this->accounts->removeAccounts($req["identifiers"]); + return HttpResponse::fromCode(HttpCodes::OK); + }, true); + } + + public function updateUser(int $id): HttpResponse { + return Control::runChecked([ + "email" => [DefaultValidators::email()], + "username" => [DefaultValidators::name()], + "isAdmin" => [DefaultValidators::bool()], + ], function (HttpRequest $req) use ($id) { + $mailAccount = $this->accounts->getAccountFromMail($req["email"]); + if ($mailAccount->getUser()->getId() != $id) { + return new JsonHttpResponse([new ValidationFail("email exists", "The provided mail address already exists for another account.")], HttpCodes::FORBIDDEN); + } + + $this->authModel->update($id, $req["email"], $req["username"], $req["isAdmin"]); + return HttpResponse::fromCode(HttpCodes::OK); + }, true); + } } diff --git a/src/Api/Controller/APIAuthController.php b/src/Api/Controller/APIAuthController.php index e257844..d21fea3 100644 --- a/src/Api/Controller/APIAuthController.php +++ b/src/Api/Controller/APIAuthController.php @@ -28,7 +28,7 @@ class APIAuthController { public function authorize(): HttpResponse { return Control::runChecked([ "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], - "password" => [DefaultValidators::lenBetween(6, 256)], + "password" => [DefaultValidators::password()], ], function (HttpRequest $req) { $failures = []; $account = $this->model->login($req["email"], $req["password"], $failures); diff --git a/src/App/Control.php b/src/App/Control.php index 0e517e4..7f04cbe 100644 --- a/src/App/Control.php +++ b/src/App/Control.php @@ -25,7 +25,7 @@ class Control { $fail = new ValidationFail("bad-payload", "request body is not a valid json object"); if ($errorInJson) { - return new JsonHttpResponse([$fail]); + return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); } return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST); @@ -49,7 +49,7 @@ class Control { if (!empty($fails)) { if ($errorInJson) { - return new JsonHttpResponse($fails); + return new JsonHttpResponse($fails, HttpCodes::BAD_REQUEST); } return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST); } diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php index 3d16733..efb8862 100644 --- a/src/App/Controller/AuthController.php +++ b/src/App/Controller/AuthController.php @@ -7,7 +7,9 @@ use IQBall\App\ViewHttpResponse; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Model\AuthModel; + use IQBall\Core\Validation\DefaultValidators; +use IQBall\Core\Validation\FieldValidationFail; class AuthController { private AuthModel $model; @@ -31,10 +33,10 @@ class AuthController { */ public function register(array $request, MutableSessionHandle $session): HttpResponse { $fails = []; - HttpRequest::from($request, $fails, [ + $request = HttpRequest::from($request, $fails, [ "username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)], - "password" => [DefaultValidators::lenBetween(6, 256)], - "confirmpassword" => [DefaultValidators::lenBetween(6, 256)], + "password" => [DefaultValidators::password()], + "confirmpassword" => [DefaultValidators::password()], "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], ]); @@ -44,7 +46,16 @@ class AuthController { } } - $account = $this->model->register($request['username'], $request["password"], $request['confirmpassword'], $request['email'], $fails); + if ($request["password"] != $request['confirmpassword']) { + $fails[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes."); + } + + $account = $this->model->register($request['username'], $request["password"], $request['email']); + + if (!$account) { + $fails[] = new FieldValidationFail("email", "L'email existe déjà"); + } + if (!empty($fails)) { return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]); } @@ -52,7 +63,7 @@ class AuthController { $target_url = $session->getInitialTarget(); if ($target_url != null) { - return HttpResponse::redirect_absolute($target_url); + return HttpResponse::redirectAbsolute($target_url); } return HttpResponse::redirect("/home"); @@ -81,7 +92,7 @@ class AuthController { $target_url = $session->getInitialTarget(); $session->setInitialTarget(null); if ($target_url != null) { - return HttpResponse::redirect_absolute($target_url); + return HttpResponse::redirectAbsolute($target_url); } return HttpResponse::redirect("/home"); diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index 6251b85..d10fb14 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -2,7 +2,6 @@ namespace IQBall\Core\Gateway; -use Cassandra\PreparedStatement; use IQBall\Core\Connection; use IQBall\Core\Data\Account; use IQBall\Core\Data\User; @@ -19,16 +18,26 @@ class AccountGateway { } public function insertAccount(string $name, string $email, string $token, string $hash, string $profilePicture): int { - $this->con->exec("INSERT INTO Account(username, hash, email, token,profile_picture) VALUES (:username,:hash,:email,:token,:profilePic)", [ + $this->con->exec("INSERT INTO Account(username, hash, email, token,profile_picture) VALUES (:username,:hash,:email,:token,:profile_pic)", [ ':username' => [$name, PDO::PARAM_STR], ':hash' => [$hash, PDO::PARAM_STR], ':email' => [$email, PDO::PARAM_STR], ':token' => [$token, PDO::PARAM_STR], - ':profilePic' => [$profilePicture, PDO::PARAM_STR], + ':profile_pic' => [$profilePicture, PDO::PARAM_STR], ]); return intval($this->con->lastInsertId()); } + public function updateAccount(int $id, string $name, string $email, string $token, bool $isAdmin): void { + $this->con->exec("UPDATE Account SET username = :username, email = :email, token = :token, is_admin = :is_admin WHERE id = :id", [ + ':username' => [$name, PDO::PARAM_STR], + ':email' => [$email, PDO::PARAM_STR], + ':token' => [$token, PDO::PARAM_STR], + ':id' => [$id, PDO::PARAM_INT], + ':is_admin' => [$isAdmin, PDO::PARAM_BOOL], + ]); + } + /** * promote or demote a user to server administrator @@ -122,7 +131,7 @@ class AccountGateway { * * @param integer $n the number of accounts to retrieve * @param int $start starting index of the list content - * @return Account[] + * @return Account[]|null */ public function listAccounts(int $start, int $n): ?array { $res = $this->con->fetch( @@ -132,7 +141,27 @@ class AccountGateway { ":n" => [$n, PDO::PARAM_INT], ] ); - return array_map(fn(array $acc) => new Account($acc["email"], new User($acc["username"], $acc["token"], $acc["id"], $acc["profile_picture"], $acc["is_admin"])), $res); + return array_map(fn(array $acc) => new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $acc["is_admin"])), $res); } + /** + * returns the total amount of accounts in the database + * @return int + */ + public function totalCount(): int { + return $this->con->fetch("SELECT count(*) FROM Account", [])[0]['count(*)']; + } + + /** + * remove a bunch of account identifiers + * @param int[] $accountIds + */ + public function removeAccounts(array $accountIds): void { + foreach ($accountIds as $accountId) { + $this->con->fetch("DELETE FROM Account WHERE id = :accountId", [ + ":accountId" => [$accountId, PDO::PARAM_INT], + ]); + } + + } } diff --git a/src/Core/Http/HttpResponse.php b/src/Core/Http/HttpResponse.php index c98a261..6c6a743 100644 --- a/src/Core/Http/HttpResponse.php +++ b/src/Core/Http/HttpResponse.php @@ -45,7 +45,7 @@ class HttpResponse { public static function redirect(string $url, int $code = HttpCodes::FOUND): HttpResponse { global $basePath; - return self::redirect_absolute($basePath . $url, $code); + return self::redirectAbsolute($basePath . $url, $code); } /** @@ -54,7 +54,7 @@ class HttpResponse { * @return HttpResponse a response that will redirect client to given url */ - public static function redirect_absolute(string $url, int $code = HttpCodes::FOUND): HttpResponse { + public static function redirectAbsolute(string $url, int $code = HttpCodes::FOUND): HttpResponse { if ($code < 300 || $code >= 400) { throw new \InvalidArgumentException("given code is not a redirection http code"); } diff --git a/src/Core/Model/AuthModel.php b/src/Core/Model/AuthModel.php index b937924..e1fc1bb 100644 --- a/src/Core/Model/AuthModel.php +++ b/src/Core/Model/AuthModel.php @@ -6,6 +6,8 @@ use Exception; use IQBall\Core\Data\Account; use IQBall\Core\Data\User; use IQBall\Core\Gateway\AccountGateway; +use IQBall\Core\Http\HttpCodes; +use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\ValidationFail; @@ -23,34 +25,19 @@ class AuthModel { /** * @param string $username * @param string $password - * @param string $confirmPassword * @param string $email - * @param ValidationFail[] $failures - * @return Account|null the registered account or null if failures occurred - * @throws Exception + * @return Account|null the registered account or null if the account already exists for the given email address */ public function register( string $username, string $password, - string $confirmPassword, - string $email, - array &$failures + string $email ): ?Account { - - if ($password != $confirmPassword) { - $failures[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes."); - } - if ($this->gateway->exists($email)) { - $failures[] = new FieldValidationFail("email", "L'email existe déjà"); - } - - if (!empty($failures)) { return null; } $hash = password_hash($password, PASSWORD_DEFAULT); - $token = $this->generateToken(); $accountId = $this->gateway->insertAccount($username, $email, $token, $hash, self::DEFAULT_PROFILE_PICTURE); return new Account($token, new User($email, $username, $accountId, self::DEFAULT_PROFILE_PICTURE, false)); @@ -59,10 +46,13 @@ class AuthModel { /** * Generate a random base 64 string * @return string - * @throws Exception */ public static function generateToken(): string { - return base64_encode(random_bytes(64)); + try { + return base64_encode(random_bytes(64)); + } catch (Exception $e) { + throw new \RuntimeException($e); + } } /** @@ -80,4 +70,9 @@ class AuthModel { return $this->gateway->getAccountFromMail($email); } + public function update(int $id, string $email, string $username, bool $isAdmin): void { + $token = $this->generateToken(); + $this->gateway->updateAccount($id, $username, $email, $token, $isAdmin); + } + } diff --git a/src/Core/Validation/DefaultValidators.php b/src/Core/Validation/DefaultValidators.php index 67d5da2..6e64e30 100644 --- a/src/Core/Validation/DefaultValidators.php +++ b/src/Core/Validation/DefaultValidators.php @@ -38,6 +38,10 @@ class DefaultValidators { return self::regex("/^[0-9a-zA-Zà-üÀ-Ü _-]*$/"); } + public static function password(): Validator { + return self::lenBetween(6, 256); + } + /** * Validate string if its length is between given range * @param int $min minimum accepted length, inclusive @@ -82,10 +86,63 @@ class DefaultValidators { ); } + /** + * @param mixed[] $values + * @return Validator + */ + public static function oneOf(array $values): Validator { + return new SimpleFunctionValidator( + fn(string $val) => in_array($val, $values), + fn(string $name) => [new FieldValidationFail($name, "The value must be one of '" . join(", ", $values) . "'")] + ); + } + + public static function bool(): Validator { + return self::oneOf([true, false]); + } + public static function isURL(): Validator { return new SimpleFunctionValidator( fn($val) => filter_var($val, FILTER_VALIDATE_URL), fn(string $name) => [new FieldValidationFail($name, "The value is not an URL")] ); } + + /** + * @return Validator + */ + public static function array(): Validator { + return new SimpleFunctionValidator( + fn($val) => is_array($val), + fn(string $name) => [new FieldValidationFail($name, "The value is not an array")] + ); + } + + /** + * @param Validator $validator + * @return Validator + */ + public static function forall(Validator $validator): Validator { + return new class ($validator) extends Validator { + private Validator $validator; + + /** + * @param Validator $validator + */ + public function __construct(Validator $validator) { + $this->validator = $validator; + } + + public function validate(string $name, $val): array { + $failures = []; + $idx = 0; + foreach ($val as $item) { + $failures = array_merge($failures, $this->validator->validate($name . "[$idx]", $item)); + $idx += 1; + } + + return $failures; + } + }; + } } From 8e400f0dd83a09553923a8bc92cbbcd58064b589 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Wed, 17 Jan 2024 10:13:26 +0100 Subject: [PATCH 15/44] add search functionnality --- src/Api/Controller/APIAccountsController.php | 4 ++-- src/Core/Gateway/AccountGateway.php | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php index 3ec62df..4ad3db7 100644 --- a/src/Api/Controller/APIAccountsController.php +++ b/src/Api/Controller/APIAccountsController.php @@ -11,7 +11,6 @@ use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\DefaultValidators; use IQBall\Core\Model\AuthModel; -use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\ValidationFail; class APIAccountsController { @@ -35,8 +34,9 @@ class APIAccountsController { return Control::runCheckedFrom($request, [ 'start' => [DefaultValidators::isUnsignedInteger()], 'n' => [DefaultValidators::isUnsignedInteger()], + 'search' => [DefaultValidators::lenBetween(0, 256)], ], function (HttpRequest $req) { - $accounts = $this->accounts->listAccounts(intval($req['start']), intval($req['n'])); + $accounts = $this->accounts->searchAccounts(intval($req['start']), intval($req['n']), $req["search"]); $users = array_map(fn(Account $acc) => $acc->getUser(), $accounts); return new JsonHttpResponse([ "users" => $users, diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index d10fb14..44a8b58 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -133,12 +133,13 @@ class AccountGateway { * @param int $start starting index of the list content * @return Account[]|null */ - public function listAccounts(int $start, int $n): ?array { + public function searchAccounts(int $start, int $n, ?string $searchString): ?array { $res = $this->con->fetch( - "SELECT * FROM Account ORDER BY email LIMIT :offset, :n", + "SELECT * FROM Account WHERE username LIKE '%' || :search || '%' OR email LIKE '%' || :search || '%' ORDER BY username, email LIMIT :offset, :n", [ ":offset" => [$start, PDO::PARAM_INT], ":n" => [$n, PDO::PARAM_INT], + ":search" => [$searchString ?? "", PDO::PARAM_STR], ] ); return array_map(fn(array $acc) => new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $acc["is_admin"])), $res); From a7f5a715323beed1d43909f3a6fccbaaa1a6f4ff Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Wed, 17 Jan 2024 11:07:03 +0100 Subject: [PATCH 16/44] fix update user --- src/Api/API.php | 1 - src/Api/Controller/APIAccountsController.php | 2 +- src/Core/Gateway/AccountGateway.php | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Api/API.php b/src/Api/API.php index b955963..cc61c8d 100644 --- a/src/Api/API.php +++ b/src/Api/API.php @@ -12,7 +12,6 @@ use IQBall\Core\Validation\ValidationFail; class API { public static function consume(HttpResponse $response): void { - error_log("consuming response" . $response->getCode()); http_response_code($response->getCode()); header('Access-Control-Allow-Origin: *'); diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php index 4ad3db7..1956b80 100644 --- a/src/Api/Controller/APIAccountsController.php +++ b/src/Api/Controller/APIAccountsController.php @@ -94,7 +94,7 @@ class APIAccountsController { "username" => [DefaultValidators::name()], "isAdmin" => [DefaultValidators::bool()], ], function (HttpRequest $req) use ($id) { - $mailAccount = $this->accounts->getAccountFromMail($req["email"]); + $mailAccount = $this->accounts->getAccount($id); if ($mailAccount->getUser()->getId() != $id) { return new JsonHttpResponse([new ValidationFail("email exists", "The provided mail address already exists for another account.")], HttpCodes::FORBIDDEN); } diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index 44a8b58..3c5070a 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -163,6 +163,5 @@ class AccountGateway { ":accountId" => [$accountId, PDO::PARAM_INT], ]); } - } } From bfb216bfafa41bb8eb441de59e7636e10f50cd89 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Wed, 17 Jan 2024 16:05:17 +0100 Subject: [PATCH 17/44] fix suggestions --- Documentation/database_mld.puml | 4 +- sql/setup-tables.sql | 9 +++- src/Api/APIControl.php | 44 +++++++++++++++++++ src/Api/Controller/APIAccountsController.php | 20 +++++---- src/Api/Controller/APIAuthController.php | 5 ++- src/Api/Controller/APITacticController.php | 12 ++--- src/App/App.php | 1 - src/App/AppControl.php | 44 +++++++++++++++++++ src/App/Controller/UserController.php | 1 + src/{App => Core}/Control.php | 26 ++++------- .../ControlSchemaErrorResponseFactory.php | 14 ++++++ src/Core/Gateway/AccountGateway.php | 26 ++++++++--- 12 files changed, 160 insertions(+), 46 deletions(-) create mode 100644 src/Api/APIControl.php create mode 100644 src/App/AppControl.php rename src/{App => Core}/Control.php (67%) create mode 100644 src/Core/ControlSchemaErrorResponseFactory.php diff --git a/Documentation/database_mld.puml b/Documentation/database_mld.puml index 170fad7..8b4d32f 100644 --- a/Documentation/database_mld.puml +++ b/Documentation/database_mld.puml @@ -5,8 +5,8 @@ object Account { name age email - phoneNumber - passwordHash + phone_number + password_hash profile_picture } diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 1a756f6..77f2b3d 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -4,6 +4,12 @@ DROP TABLE IF EXISTS Tactic; DROP TABLE IF EXISTS Team; DROP TABLE IF EXISTS User; DROP TABLE IF EXISTS Member; + +CREATE TABLE Admins +( + id integer PRIMARY KEY REFERENCES Account +); + CREATE TABLE Account ( id integer PRIMARY KEY AUTOINCREMENT, @@ -11,8 +17,7 @@ CREATE TABLE Account username varchar NOT NULL, token varchar UNIQUE NOT NULL, hash varchar NOT NULL, - profile_picture varchar NOT NULL, - is_admin boolean DEFAULT false NOT NULL + profile_picture varchar NOT NULL ); CREATE TABLE Tactic diff --git a/src/Api/APIControl.php b/src/Api/APIControl.php new file mode 100644 index 0000000..260575a --- /dev/null +++ b/src/Api/APIControl.php @@ -0,0 +1,44 @@ + $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runChecked(array $schema, callable $run): HttpResponse { + return Control::runChecked($schema, $run, self::errorFactory()); + } + + /** + * Runs given callback, if the given request data array validates the given schema. + * @param array $data the request's data array. + * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { + return Control::runCheckedFrom($data, $schema, $run, self::errorFactory()); + } + +} diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php index 1956b80..13d9db4 100644 --- a/src/Api/Controller/APIAccountsController.php +++ b/src/Api/Controller/APIAccountsController.php @@ -2,6 +2,7 @@ namespace IQBall\Api\Controller; +use IQBall\Api\APIControl; use IQBall\App\Control; use IQBall\Core\Data\Account; use IQBall\Core\Gateway\AccountGateway; @@ -9,8 +10,8 @@ use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; -use IQBall\Core\Validation\DefaultValidators; use IQBall\Core\Model\AuthModel; +use IQBall\Core\Validation\DefaultValidators; use IQBall\Core\Validation\ValidationFail; class APIAccountsController { @@ -18,6 +19,7 @@ class APIAccountsController { private AuthModel $authModel; /** + * @param AuthModel $model * @param AccountGateway $accounts */ public function __construct(AuthModel $model, AccountGateway $accounts) { @@ -31,7 +33,7 @@ class APIAccountsController { * @return HttpResponse */ public function listUsers(array $request): HttpResponse { - return Control::runCheckedFrom($request, [ + return APIControl::runCheckedFrom($request, [ 'start' => [DefaultValidators::isUnsignedInteger()], 'n' => [DefaultValidators::isUnsignedInteger()], 'search' => [DefaultValidators::lenBetween(0, 256)], @@ -42,7 +44,7 @@ class APIAccountsController { "users" => $users, "totalCount" => $this->accounts->totalCount(), ]); - }, true); + }); } /** @@ -60,7 +62,7 @@ class APIAccountsController { } public function addUser(): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "username" => [DefaultValidators::name()], "email" => [DefaultValidators::email()], "password" => [DefaultValidators::password()], @@ -76,20 +78,20 @@ class APIAccountsController { return new JsonHttpResponse([ "id" => $account->getUser()->getId(), ]); - }, true); + }); } public function removeUsers(): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "identifiers" => [DefaultValidators::array(), DefaultValidators::forall(DefaultValidators::isUnsignedInteger())], ], function (HttpRequest $req) { $this->accounts->removeAccounts($req["identifiers"]); return HttpResponse::fromCode(HttpCodes::OK); - }, true); + }); } public function updateUser(int $id): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "email" => [DefaultValidators::email()], "username" => [DefaultValidators::name()], "isAdmin" => [DefaultValidators::bool()], @@ -101,6 +103,6 @@ class APIAccountsController { $this->authModel->update($id, $req["email"], $req["username"], $req["isAdmin"]); return HttpResponse::fromCode(HttpCodes::OK); - }, true); + }); } } diff --git a/src/Api/Controller/APIAuthController.php b/src/Api/Controller/APIAuthController.php index d21fea3..c715803 100644 --- a/src/Api/Controller/APIAuthController.php +++ b/src/Api/Controller/APIAuthController.php @@ -2,6 +2,7 @@ namespace IQBall\Api\Controller; +use IQBall\Api\APIControl; use IQBall\App\Control; use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpRequest; @@ -26,7 +27,7 @@ class APIAuthController { * @return HttpResponse */ public function authorize(): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], "password" => [DefaultValidators::password()], ], function (HttpRequest $req) { @@ -38,6 +39,6 @@ class APIAuthController { } return new JsonHttpResponse(["authorization" => $account->getToken()]); - }, true); + }); } } diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index 0d99f41..9f71212 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -2,7 +2,7 @@ namespace IQBall\Api\Controller; -use IQBall\App\Control; +use IQBall\Api\APIControl; use IQBall\Core\Data\Account; use IQBall\Core\Data\TacticInfo; use IQBall\Core\Http\HttpCodes; @@ -32,7 +32,7 @@ class APITacticController { * @return HttpResponse */ public function updateName(int $tactic_id, Account $account): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "name" => [DefaultValidators::lenBetween(1, 50), DefaultValidators::nameWithSpaces()], ], function (HttpRequest $request) use ($tactic_id, $account) { @@ -44,23 +44,23 @@ class APITacticController { } return HttpResponse::fromCode(HttpCodes::OK); - }, true); + }); } /** * @param int $id - * @param Account $account * @return HttpResponse */ public function saveContent(int $id, Account $account): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "content" => [], ], function (HttpRequest $req) use ($id) { + //TODO verify that the account has the rights to update the tactic content if ($fail = $this->model->updateContent($id, json_encode($req["content"]))) { return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); } return HttpResponse::fromCode(HttpCodes::OK); - }, true); + }); } diff --git a/src/App/App.php b/src/App/App.php index cba59de..5f208bc 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -90,7 +90,6 @@ class App { if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); } - } return $action->run($params, $session); diff --git a/src/App/AppControl.php b/src/App/AppControl.php new file mode 100644 index 0000000..c313e69 --- /dev/null +++ b/src/App/AppControl.php @@ -0,0 +1,44 @@ + $failures], HttpCodes::BAD_REQUEST); + } + }; + } + + /** + * Runs given callback, if the request's payload json validates the given schema. + * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runChecked(array $schema, callable $run): HttpResponse { + return Control::runChecked($schema, $run, self::errorFactory()); + } + + /** + * Runs given callback, if the given request data array validates the given schema. + * @param array $data the request's data array. + * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { + return Control::runCheckedFrom($data, $schema, $run, self::errorFactory()); + } + +} diff --git a/src/App/Controller/UserController.php b/src/App/Controller/UserController.php index 6f56128..616bf54 100644 --- a/src/App/Controller/UserController.php +++ b/src/App/Controller/UserController.php @@ -5,6 +5,7 @@ namespace IQBall\App\Controller; use IQBall\App\Session\MutableSessionHandle; use IQBall\App\Session\SessionHandle; use IQBall\App\ViewHttpResponse; +use IQBall\Core\Gateway\AccountGateway; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Model\TacticModel; use IQBall\Core\Model\TeamModel; diff --git a/src/App/Control.php b/src/Core/Control.php similarity index 67% rename from src/App/Control.php rename to src/Core/Control.php index 7f04cbe..106052d 100644 --- a/src/App/Control.php +++ b/src/Core/Control.php @@ -1,6 +1,6 @@ $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. * The callback must accept an HttpRequest, and return an HttpResponse object. - * @param bool $errorInJson if set to true, the returned response will be a JsonHttpResponse if the schema isn't validated + * @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema * @return HttpResponse */ - public static function runChecked(array $schema, callable $run, bool $errorInJson = false): HttpResponse { + public static function runChecked(array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): HttpResponse { $request_body = file_get_contents('php://input'); $payload_obj = json_decode($request_body); if (!$payload_obj instanceof \stdClass) { $fail = new ValidationFail("bad-payload", "request body is not a valid json object"); - - if ($errorInJson) { - return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); - } - - return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST); + return $errorFactory->apply([$fail]); } $payload = get_object_vars($payload_obj); - return self::runCheckedFrom($payload, $schema, $run, $errorInJson); + return self::runCheckedFrom($payload, $schema, $run, $errorFactory); } /** @@ -40,18 +35,15 @@ class Control { * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. * The callback must accept an HttpRequest, and return an HttpResponse object. - * @param bool $errorInJson if set to true, the returned response will be a JsonHttpResponse if the schema isn't validated + * @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema * @return HttpResponse */ - public static function runCheckedFrom(array $data, array $schema, callable $run, bool $errorInJson = false): HttpResponse { + public static function runCheckedFrom(array $data, array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): 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 $errorFactory->apply($fails); } return call_user_func_array($run, [$request]); diff --git a/src/Core/ControlSchemaErrorResponseFactory.php b/src/Core/ControlSchemaErrorResponseFactory.php new file mode 100644 index 0000000..9882a65 --- /dev/null +++ b/src/Core/ControlSchemaErrorResponseFactory.php @@ -0,0 +1,14 @@ +con->exec("UPDATE Account SET username = :username, email = :email, token = :token, is_admin = :is_admin WHERE id = :id", [ + $this->con->exec("UPDATE Account SET username = :username, email = :email, token = :token WHERE id = :id", [ ':username' => [$name, PDO::PARAM_STR], ':email' => [$email, PDO::PARAM_STR], ':token' => [$token, PDO::PARAM_STR], ':id' => [$id, PDO::PARAM_INT], - ':is_admin' => [$isAdmin, PDO::PARAM_BOOL], ]); + $this->setIsAdmin($id, $isAdmin); + } + + public function isAdmin(int $id): bool { + $stmnt = $this->con->prepare("SELECT * FROM Admins WHERE id = :id"); + $stmnt->bindValue(':id', $id, PDO::PARAM_INT); + $stmnt->execute(); + $result = $stmnt->fetchAll(PDO::FETCH_ASSOC); + + return !empty($result); } @@ -46,8 +55,11 @@ class AccountGateway { * @return bool true if the given user exists */ public function setIsAdmin(int $id, bool $isAdmin): bool { - $stmnt = $this->con->prepare("UPDATE Account SET is_admin = :is_admin WHERE id = :id"); - $stmnt->bindValue(':is_admin', $isAdmin); + if ($isAdmin) { + $stmnt = $this->con->prepare("INSERT INTO Admins VALUES(:id)"); + } else { + $stmnt = $this->con->prepare("DELETE FROM Admins WHERE id = :id"); + } $stmnt->bindValue(':id', $id); $stmnt->execute(); @@ -92,7 +104,7 @@ class AccountGateway { return null; } - return new Account($acc["token"], new User($email, $acc["username"], $acc["id"], $acc["profile_picture"], $acc['is_admin'])); + return new Account($acc["token"], new User($email, $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))); } /** @@ -123,7 +135,7 @@ class AccountGateway { return null; } - return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $acc["is_admin"])); + return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))); } /** @@ -142,7 +154,7 @@ class AccountGateway { ":search" => [$searchString ?? "", PDO::PARAM_STR], ] ); - return array_map(fn(array $acc) => new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $acc["is_admin"])), $res); + return array_map(fn(array $acc) => new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))), $res); } /** From 2ef68eacf1190fc70cdd64cec3e9e52d9b47d0e1 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Wed, 17 Jan 2024 17:37:43 +0100 Subject: [PATCH 18/44] apply suggestions --- config.php | 6 ++++++ profiles/dev-config-profile.php | 16 ++++++++++++++++ profiles/prod-config-profile.php | 3 +++ sql/database.php | 17 ----------------- src/Api/APIControl.php | 3 ++- src/Api/Controller/APIAccountsController.php | 2 +- src/Core/Gateway/AccountGateway.php | 4 ++-- src/Core/Model/TacticModel.php | 4 ++-- src/Core/Validation/DefaultValidators.php | 6 ++---- 9 files changed, 34 insertions(+), 27 deletions(-) diff --git a/config.php b/config.php index fdf02a4..0dd030a 100644 --- a/config.php +++ b/config.php @@ -3,6 +3,7 @@ // `dev-config-profile.php` by default. // on production server the included profile is `prod-config-profile.php`. // Please do not touch. + require /*PROFILE_FILE*/ "profiles/dev-config-profile.php"; const SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH; @@ -21,3 +22,8 @@ global $_data_source_name; $data_source_name = $_data_source_name; const DATABASE_USER = _DATABASE_USER; const DATABASE_PASSWORD = _DATABASE_PASSWORD; + + +function init_database(PDO $pdo): void { + _init_database($pdo); +} diff --git a/profiles/dev-config-profile.php b/profiles/dev-config-profile.php index bd87f1d..e39f2f0 100644 --- a/profiles/dev-config-profile.php +++ b/profiles/dev-config-profile.php @@ -1,5 +1,9 @@ insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT), "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"); + $accounts->setIsAdmin($id, true); + } +} diff --git a/profiles/prod-config-profile.php b/profiles/prod-config-profile.php index e9bb12c..185541a 100644 --- a/profiles/prod-config-profile.php +++ b/profiles/prod-config-profile.php @@ -20,3 +20,6 @@ function _asset(string $assetURI): string { // fallback to the uri itself. return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI); } + + +function _init_database(PDO $pdo): void {} diff --git a/sql/database.php b/sql/database.php index af94ef2..69b53e7 100644 --- a/sql/database.php +++ b/sql/database.php @@ -1,9 +1,5 @@ insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT), "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"); - $accounts->setIsAdmin($id, true); - } -} diff --git a/src/Api/APIControl.php b/src/Api/APIControl.php index 260575a..751fbfb 100644 --- a/src/Api/APIControl.php +++ b/src/Api/APIControl.php @@ -4,6 +4,7 @@ namespace IQBall\Api; use IQBall\Core\Control; use IQBall\Core\ControlSchemaErrorResponseFactory; +use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; @@ -13,7 +14,7 @@ class APIControl { private static function errorFactory(): ControlSchemaErrorResponseFactory { return new class () implements ControlSchemaErrorResponseFactory { public function apply(array $failures): HttpResponse { - return new JsonHttpResponse($failures); + return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST); } }; } diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php index 13d9db4..32fd956 100644 --- a/src/Api/Controller/APIAccountsController.php +++ b/src/Api/Controller/APIAccountsController.php @@ -35,7 +35,7 @@ class APIAccountsController { public function listUsers(array $request): HttpResponse { return APIControl::runCheckedFrom($request, [ 'start' => [DefaultValidators::isUnsignedInteger()], - 'n' => [DefaultValidators::isUnsignedInteger()], + 'n' => [DefaultValidators::isIntInRange(0, 250)], 'search' => [DefaultValidators::lenBetween(0, 256)], ], function (HttpRequest $req) { $accounts = $this->accounts->searchAccounts(intval($req['start']), intval($req['n']), $req["search"]); diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index d6b5686..6752b01 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -143,9 +143,9 @@ class AccountGateway { * * @param integer $n the number of accounts to retrieve * @param int $start starting index of the list content - * @return Account[]|null + * @return Account[] */ - public function searchAccounts(int $start, int $n, ?string $searchString): ?array { + public function searchAccounts(int $start, int $n, ?string $searchString): array { $res = $this->con->fetch( "SELECT * FROM Account WHERE username LIKE '%' || :search || '%' OR email LIKE '%' || :search || '%' ORDER BY username, email LIMIT :offset, :n", [ diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php index 590a106..920075b 100644 --- a/src/Core/Model/TacticModel.php +++ b/src/Core/Model/TacticModel.php @@ -68,9 +68,9 @@ class TacticModel { * NOTE: if given user id does not match any user, this function returns an empty array * * @param integer $user_id - * @return TacticInfo[] | null + * @return TacticInfo[] */ - public function listAllOf(int $user_id): ?array { + public function listAllOf(int $user_id): array { return$this->tactics->listAllOf($user_id); } diff --git a/src/Core/Validation/DefaultValidators.php b/src/Core/Validation/DefaultValidators.php index 6e64e30..c898170 100644 --- a/src/Core/Validation/DefaultValidators.php +++ b/src/Core/Validation/DefaultValidators.php @@ -72,7 +72,7 @@ class DefaultValidators { public static function isInteger(): Validator { - return self::regex("/^-[0-9]+$/", "field is not an integer"); + return self::regex("/^[-+]?[0-9]+$/", "field is not an integer"); } public static function isUnsignedInteger(): Validator { @@ -135,10 +135,8 @@ class DefaultValidators { public function validate(string $name, $val): array { $failures = []; - $idx = 0; - foreach ($val as $item) { + foreach ($val as $idx => $item) { $failures = array_merge($failures, $this->validator->validate($name . "[$idx]", $item)); - $idx += 1; } return $failures; From 5aa1d1fbee9a4b0fc056e1c09fa6dd539ec85210 Mon Sep 17 00:00:00 2001 From: maxime Date: Sat, 20 Jan 2024 19:18:53 +0100 Subject: [PATCH 19/44] clarify documentation --- Documentation/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation/README.md b/Documentation/README.md index dfc91c7..24c5472 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -1,3 +1,3 @@ -# The wiki also exists - -Some of our explanation are contained in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki) \ No newline at end of file +* [Description.md](Description.md) +* [Conception.md](Conception.md) +* [how-to-dev.md](how-to-dev.md) \ No newline at end of file From 8d444c38b48d16eff592bb6791f4ebc18b5b6a90 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 4 Jan 2024 19:20:37 +0100 Subject: [PATCH 20/44] use one array of TacticComponent --- front/components/arrows/BendableArrow.tsx | 2 +- front/components/editor/BallPiece.tsx | 3 +- front/components/editor/BasketCourt.tsx | 256 +++++++++---------- front/components/editor/CourtBall.tsx | 15 +- front/components/editor/CourtPlayer.tsx | 5 +- front/{components/arrows => geo}/Box.ts | 8 + front/{components/arrows => geo}/Pos.ts | 0 front/model/tactic/Action.ts | 9 +- front/model/tactic/Ball.ts | 22 +- front/model/tactic/Player.ts | 15 +- front/model/tactic/Tactic.ts | 32 ++- front/views/Editor.tsx | 284 +++++++++++----------- front/views/editor/CourtAction.tsx | 2 +- front/views/template/Header.tsx | 2 +- sql/setup-tables.sql | 2 +- src/App/Controller/EditorController.php | 2 +- src/App/Views/home.twig | 2 +- 17 files changed, 353 insertions(+), 308 deletions(-) rename front/{components/arrows => geo}/Box.ts (81%) rename front/{components/arrows => geo}/Pos.ts (100%) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index b8f0f19..e2219bb 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -22,7 +22,7 @@ import { ratioWithinBase, relativeTo, norm, -} from "./Pos" +} from "../../geo/Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index 2741249..d72ad75 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,7 +1,8 @@ import "../../style/ball.css" import BallSvg from "../../assets/icon/ball.svg?react" +import {BALL_ID} from "../../model/tactic/Ball"; export function BallPiece() { - return + return } diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 525e232..7aba76c 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -12,16 +12,17 @@ import CourtPlayer from "./CourtPlayer" import { Player } from "../../model/tactic/Player" import { Action, ActionKind } from "../../model/tactic/Action" import ArrowAction from "../actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../arrows/Pos" +import { middlePos, ratioWithinBase } from "../../geo/Pos" import BallAction from "../actions/BallAction" -import { CourtObject } from "../../model/tactic/Ball" -import { contains } from "../arrows/Box" +import {BALL_ID} from "../../model/tactic/Ball" +import { contains, overlaps } from "../../geo/Box" + import { CourtAction } from "../../views/editor/CourtAction" +import { TacticComponent } from "../../model/tactic/Tactic" export interface BasketCourtProps { - players: Player[] + components: TacticComponent[] actions: Action[] - objects: CourtObject[] renderAction: (a: Action, key: number) => ReactElement setActions: (f: (a: Action[]) => Action[]) => void @@ -37,9 +38,8 @@ export interface BasketCourtProps { } export function BasketCourt({ - players, + components, actions, - objects, renderAction, setActions, onPlayerRemove, @@ -59,33 +59,31 @@ export function BasketCourt({ courtBounds, ) - for (const player of players) { - if (player.id == origin.id) { + for (const component of components) { + if (component.id == origin.id) { continue } const playerBounds = document - .getElementById(player.id)! + .getElementById(component.id)! .getBoundingClientRect() - if ( - !( - playerBounds.top > arrowHead.bottom || - playerBounds.right < arrowHead.left || - playerBounds.bottom < arrowHead.top || - playerBounds.left > arrowHead.right - ) - ) { + if (overlaps(playerBounds, arrowHead)) { const targetPos = document - .getElementById(player.id)! + .getElementById(component.id)! .getBoundingClientRect() const end = ratioWithinBase(middlePos(targetPos), courtBounds) const action: Action = { - fromPlayerId: originRef.id, - toPlayerId: player.id, - type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN, + fromId: originRef.id, + toId: component.id, + type: + component.type == "player" + ? origin.hasBall + ? ActionKind.SHOOT + : ActionKind.SCREEN + : ActionKind.MOVE, moveFrom: start, segments: [{ next: end }], } @@ -95,7 +93,7 @@ export function BasketCourt({ } const action: Action = { - fromPlayerId: originRef.id, + fromId: originRef.id, type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE, moveFrom: ratioWithinBase( middlePos(originRef.getBoundingClientRect()), @@ -110,20 +108,20 @@ export function BasketCourt({ const [previewAction, setPreviewAction] = useState(null) - const updateActionsRelatedTo = useCallback((player: Player) => { + const updateActionsRelatedTo = useCallback((comp: TacticComponent) => { const newPos = ratioWithinBase( middlePos( - document.getElementById(player.id)!.getBoundingClientRect(), + document.getElementById(comp.id)!.getBoundingClientRect(), ), courtRef.current!.getBoundingClientRect(), ) setActions((actions) => actions.map((a) => { - if (a.fromPlayerId == player.id) { + if (a.fromId == comp.id) { return { ...a, moveFrom: newPos } } - if (a.toPlayerId == player.id) { + if (a.toId == comp.id) { const segments = a.segments.toSpliced( a.segments.length - 1, 1, @@ -151,113 +149,125 @@ export function BasketCourt({ style={{ position: "relative" }}> {courtImage} - {players.map((player) => ( - updateActionsRelatedTo(player)} - onChange={onPlayerChange} - onRemove={() => onPlayerRemove(player)} - courtRef={courtRef} - availableActions={(pieceRef) => [ - { - const baseBounds = - courtRef.current!.getBoundingClientRect() - - const arrowHeadPos = middlePos(headPos) - - const target = players.find( - (p) => - p != player && - contains( - document - .getElementById(p.id)! - .getBoundingClientRect(), - arrowHeadPos, - ), - ) - - setPreviewAction((action) => ({ - ...action!, - segments: [ - { - next: ratioWithinBase( - arrowHeadPos, - baseBounds, - ), - }, - ], - type: player.hasBall - ? target - ? ActionKind.SHOOT - : ActionKind.DRIBBLE - : target - ? ActionKind.SCREEN - : ActionKind.MOVE, - })) - }} - onHeadPicked={(headPos) => { - ;(document.activeElement as HTMLElement).blur() - const baseBounds = - courtRef.current!.getBoundingClientRect() - - setPreviewAction({ - type: player.hasBall - ? ActionKind.DRIBBLE - : ActionKind.MOVE, - fromPlayerId: player.id, - toPlayerId: undefined, - moveFrom: ratioWithinBase( - middlePos( - pieceRef.getBoundingClientRect(), - ), - baseBounds, - ), - segments: [ - { - next: ratioWithinBase( - middlePos(headPos), - baseBounds, - ), - }, - ], - }) - }} - onHeadDropped={(headRect) => { - placeArrow(player, headRect) - setPreviewAction(null) - }} - />, - player.hasBall && ( - - onBallMoved(ref.getBoundingClientRect()) - } - /> - ), - ]} - /> - ))} + {components.map((component) => { + if (component.type == "player") { + const player = component + return ( + updateActionsRelatedTo(player)} + onChange={onPlayerChange} + onRemove={() => onPlayerRemove(player)} + courtRef={courtRef} + availableActions={(pieceRef) => [ + { + const baseBounds = + courtRef.current!.getBoundingClientRect() - {internActions.map((action, idx) => renderAction(action, idx))} + const arrowHeadPos = middlePos(headPos) + + const target = components.find( + (c) => + c.id != player.id && + contains( + document + .getElementById(c.id)! + .getBoundingClientRect(), + arrowHeadPos, + ), + ) - {objects.map((object) => { - if (object.type == "ball") { + const type = + target?.type == "player" + ? player.hasBall + ? target + ? ActionKind.SHOOT + : ActionKind.DRIBBLE + : target + ? ActionKind.SCREEN + : ActionKind.MOVE + : ActionKind.MOVE + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase( + arrowHeadPos, + baseBounds, + ), + }, + ], + type, + })) + }} + onHeadPicked={(headPos) => { + ;( + document.activeElement as HTMLElement + ).blur() + const baseBounds = + courtRef.current!.getBoundingClientRect() + + setPreviewAction({ + type: player.hasBall + ? ActionKind.DRIBBLE + : ActionKind.MOVE, + fromId: player.id, + toId: undefined, + moveFrom: ratioWithinBase( + middlePos( + pieceRef.getBoundingClientRect(), + ), + baseBounds, + ), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + baseBounds, + ), + }, + ], + }) + }} + onHeadDropped={(headRect) => { + placeArrow(player, headRect) + setPreviewAction(null) + }} + />, + player.hasBall && ( + + onBallMoved( + ref.getBoundingClientRect(), + ) + } + /> + ), + ]} + /> + ) + } + if (component.type == BALL_ID) { return ( updateActionsRelatedTo(component)} + ball={component} onRemove={onBallRemove} key="ball" /> ) } - throw new Error("unknown court object" + object.type) + throw new Error("unknown tactic component " + component) })} + {internActions.map((action, idx) => renderAction(action, idx))} + {previewAction && ( void + onPosValidated: (rect: DOMRect) => void + onMoves: () => void onRemove: () => void ball: Ball } -export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { +export function CourtBall({ + onPosValidated, + ball, + onRemove, + onMoves, +}: CourtBallProps) { const pieceRef = useRef(null) const x = ball.rightRatio @@ -17,7 +23,10 @@ export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { return ( onMoved(pieceRef.current!.getBoundingClientRect())} + onStop={() => + onPosValidated(pieceRef.current!.getBoundingClientRect()) + } + onDrag={onMoves} nodeRef={pieceRef}>
= box.x && diff --git a/front/components/arrows/Pos.ts b/front/geo/Pos.ts similarity index 100% rename from front/components/arrows/Pos.ts rename to front/geo/Pos.ts diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index 0b5aee5..d238398 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -1,6 +1,7 @@ -import { Pos } from "../../components/arrows/Pos" + +import { Pos } from "../../geo/Pos" import { Segment } from "../../components/arrows/BendableArrow" -import { PlayerId } from "./Player" +import { ComponentId } from "./Tactic" export enum ActionKind { SCREEN = "SCREEN", @@ -12,8 +13,8 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - fromPlayerId: PlayerId - toPlayerId?: PlayerId + fromId: ComponentId + toId?: ComponentId moveFrom: Pos segments: Segment[] } diff --git a/front/model/tactic/Ball.ts b/front/model/tactic/Ball.ts index 28e4830..96cde26 100644 --- a/front/model/tactic/Ball.ts +++ b/front/model/tactic/Ball.ts @@ -1,17 +1,9 @@ -export type CourtObject = { type: "ball" } & Ball +import { Component } from "./Tactic" -export interface Ball { - /** - * The ball is a "ball" court object - */ - readonly type: "ball" +export const BALL_ID = "ball" +export const BALL_TYPE = "ball" - /** - * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) - */ - readonly bottomRatio: number - /** - * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) - */ - readonly rightRatio: number -} +//place here all different kinds of objects +export type CourtObject = Ball + +export type Ball = Component diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index f94d6bf..e558496 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -1,3 +1,5 @@ +import {Component} from "./Tactic"; + export type PlayerId = string export enum PlayerTeam { @@ -7,7 +9,9 @@ export enum PlayerTeam { export interface Player { readonly id: PlayerId +} +export interface Player extends Component<"player"> { /** * the player's team * */ @@ -18,18 +22,9 @@ export interface Player { * */ readonly role: string - /** - * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) - */ - readonly bottomRatio: number - - /** - * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) - */ - readonly rightRatio: number - /** * True if the player has a basketball */ readonly hasBall: boolean } + diff --git a/front/model/tactic/Tactic.ts b/front/model/tactic/Tactic.ts index 2eab85b..6580dbb 100644 --- a/front/model/tactic/Tactic.ts +++ b/front/model/tactic/Tactic.ts @@ -1,6 +1,6 @@ -import { Player } from "./Player" -import { CourtObject } from "./Ball" -import { Action } from "./Action" +import {Player} from "./Player" +import {Action} from "./Action" +import {CourtObject} from "./Ball" export interface Tactic { id: number @@ -9,7 +9,29 @@ export interface Tactic { } export interface TacticContent { - players: Player[] - objects: CourtObject[] + components: TacticComponent[] actions: Action[] } + +export type TacticComponent = Player | CourtObject +export type ComponentId = string + +export interface Component { + /** + * The component's type + */ + readonly type: T + /** + * The component's identifier + */ + readonly id: ComponentId + /** + * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + */ + readonly bottomRatio: number + + /** + * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number +} diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index cbb2da5..387ad74 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,38 +1,27 @@ -import { - CSSProperties, - Dispatch, - SetStateAction, - useCallback, - useMemo, - useRef, - useState, -} from "react" +import {CSSProperties, Dispatch, SetStateAction, useCallback, useMemo, useRef, useState,} from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import { BallPiece } from "../components/editor/BallPiece" +import {BallPiece} from "../components/editor/BallPiece" -import { Rack } from "../components/Rack" -import { PlayerPiece } from "../components/editor/PlayerPiece" -import { Player } from "../model/tactic/Player" +import {Rack} from "../components/Rack" +import {PlayerPiece} from "../components/editor/PlayerPiece" +import {Player, PlayerTeam} from "../model/tactic/Player" -import { Tactic, TacticContent } from "../model/tactic/Tactic" -import { fetchAPI } from "../Fetcher" -import { PlayerTeam } from "../model/tactic/Player" +import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" +import {fetchAPI} from "../Fetcher" -import SavingState, { - SaveState, - SaveStates, -} from "../components/editor/SavingState" +import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" -import { CourtObject } from "../model/tactic/Ball" -import { CourtAction } from "./editor/CourtAction" -import { BasketCourt } from "../components/editor/BasketCourt" -import { ratioWithinBase } from "../components/arrows/Pos" -import { Action, ActionKind } from "../model/tactic/Action" -import { BASE } from "../Constants" +import {BALL_ID, BALL_TYPE, CourtObject, Ball} from "../model/tactic/Ball" +import {CourtAction} from "./editor/CourtAction" +import {BasketCourt} from "../components/editor/BasketCourt" +import {Action, ActionKind} from "../model/tactic/Action" +import {BASE} from "../Constants" +import {overlaps} from "../geo/Box" +import {ratioWithinBase} from "../geo/Pos" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -135,10 +124,10 @@ function EditorView({ ) const [allies, setAllies] = useState( - getRackPlayers(PlayerTeam.Allies, content.players), + getRackPlayers(PlayerTeam.Allies, content.components), ) const [opponents, setOpponents] = useState( - getRackPlayers(PlayerTeam.Opponents, content.players), + getRackPlayers(PlayerTeam.Opponents, content.components), ) const [objects, setObjects] = useState( @@ -151,15 +140,10 @@ function EditorView({ const courtBounds = courtDivContentRef.current!.getBoundingClientRect() // check if refBounds overlaps courtBounds - return !( - bounds.top > courtBounds.bottom || - bounds.right < courtBounds.left || - bounds.bottom < courtBounds.top || - bounds.left > courtBounds.right - ) + return overlaps(courtBounds, bounds) } - const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { + const onRackPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() @@ -168,23 +152,24 @@ function EditorView({ setContent((content) => { return { ...content, - players: [ - ...content.players, + components: [ + ...content.components, { + type: "player", id: "player-" + element.key + "-" + element.team, team: element.team, role: element.key, rightRatio: x, bottomRatio: y, hasBall: false, - }, + } as Player, ], actions: content.actions, } }) } - const onObjectDetach = ( + const onRackedObjectDetach = ( ref: HTMLDivElement, rackedObject: RackedCourtObject, ) => { @@ -196,27 +181,22 @@ function EditorView({ let courtObject: CourtObject switch (rackedObject.key) { - case "ball": - const ballObj = content.objects.findIndex( - (o) => o.type == "ball", + case BALL_TYPE: + const ballObj = content.components.findIndex( + (o) => o.type == BALL_TYPE, ) - const playerCollidedIdx = getPlayerCollided( + const playerCollidedIdx = getComponentCollided( refBounds, - content.players, + content.components.toSpliced(ballObj, 1), ) if (playerCollidedIdx != -1) { - onBallDropOnPlayer(playerCollidedIdx) - setContent((content) => { - return { - ...content, - objects: content.objects.toSpliced(ballObj, 1), - } - }) + onBallDropOnComponent(playerCollidedIdx) return } courtObject = { - type: "ball", + type: BALL_TYPE, + id: BALL_ID, rightRatio: x, bottomRatio: y, } @@ -229,38 +209,34 @@ function EditorView({ setContent((content) => { return { ...content, - objects: [...content.objects, courtObject], + components: [...content.components, courtObject], } }) } - const getPlayerCollided = ( + const getComponentCollided = ( bounds: DOMRect, - players: Player[], + components: TacticComponent[], ): number | -1 => { - for (let i = 0; i < players.length; i++) { - const player = players[i] + for (let i = 0; i < components.length; i++) { + const component = components[i] const playerBounds = document - .getElementById(player.id)! + .getElementById(component.id)! .getBoundingClientRect() - const doesOverlap = !( - bounds.top > playerBounds.bottom || - bounds.right < playerBounds.left || - bounds.bottom < playerBounds.top || - bounds.left > playerBounds.right - ) - if (doesOverlap) { + if (overlaps(playerBounds, bounds)) { return i } } return -1 } - function updateActions(actions: Action[], players: Player[]) { + function updateActions(actions: Action[], components: TacticComponent[]) { return actions.map((action) => { - const originHasBall = players.find( - (p) => p.id == action.fromPlayerId, - )!.hasBall + const originHasBall = ( + components.find( + (p) => p.type == "player" && p.id == action.fromId, + )! as Player + ).hasBall let type = action.type @@ -280,80 +256,101 @@ function EditorView({ }) } - const onBallDropOnPlayer = (playerCollidedIdx: number) => { + const onBallDropOnComponent = (collidedComponentIdx: number) => { setContent((content) => { - const ballObj = content.objects.findIndex((o) => o.type == "ball") - let player = content.players.at(playerCollidedIdx) as Player - const players = content.players.toSpliced(playerCollidedIdx, 1, { - ...player, - hasBall: true, - }) + const ballObj = content.components.findIndex( + (p) => p.type == BALL_TYPE, + ) + let component = content.components[collidedComponentIdx] + if (component.type != "player") { + return content //do nothing if the ball isn't dropped on a player. + } + const components = content.components.toSpliced( + collidedComponentIdx, + 1, + { + ...component, + hasBall: true, + }, + ) + // Maybe the ball is not present on the court as an object component + // if so, don't bother removing it from the court. + // This can occur if the user drags and drop the ball from a player that already has the ball + // to another component + if (ballObj != -1) { + components.splice(ballObj, 1) + } return { ...content, - actions: updateActions(content.actions, players), - players, - objects: content.objects.toSpliced(ballObj, 1), + actions: updateActions(content.actions, components), + components, } }) } - const onBallDrop = (refBounds: DOMRect) => { + const onBallMoved = (refBounds: DOMRect) => { if (!isBoundsOnCourt(refBounds)) { removeCourtBall() return } - const playerCollidedIdx = getPlayerCollided(refBounds, content.players) + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + ) if (playerCollidedIdx != -1) { setContent((content) => { return { ...content, - players: content.players.map((player) => ({ - ...player, - hasBall: false, - })), + components: content.components.map((c) => + c.type == "player" + ? { + ...c, + hasBall: false, + } + : c, + ), } }) - onBallDropOnPlayer(playerCollidedIdx) + onBallDropOnComponent(playerCollidedIdx) return } - if (content.objects.findIndex((o) => o.type == "ball") != -1) { + if (content.components.findIndex((o) => o.type == "ball") != -1) { return } const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(refBounds, courtBounds) - let courtObject: CourtObject - - courtObject = { - type: "ball", + const courtObject = { + type: BALL_TYPE, + id: BALL_ID, rightRatio: x, bottomRatio: y, - } + } as Ball + + let components = content.components.map((c) => + c.type == "player" + ? { + ...c, + hasBall: false, + } + : c, + ) + components = [...components, courtObject] - const players = content.players.map((player) => ({ - ...player, - hasBall: false, + setContent((content) => ({ + ...content, + actions: updateActions(content.actions, components), + components, })) - - setContent((content) => { - return { - ...content, - actions: updateActions(content.actions, players), - players, - objects: [...content.objects, courtObject], - } - }) } const removePlayer = (player: Player) => { setContent((content) => ({ ...content, - players: toSplicedPlayers(content.players, player, false), - objects: [...content.objects], + components: replaceOrInsert(content.components, player, false), actions: content.actions.filter( - (a) => - a.toPlayerId !== player.id && a.fromPlayerId !== player.id, + (a) => a.toId !== player.id && a.fromId !== player.id, ), })) let setter @@ -379,14 +376,21 @@ function EditorView({ const removeCourtBall = () => { setContent((content) => { - const ballObj = content.objects.findIndex((o) => o.type == "ball") + const ballObj = content.components.findIndex( + (o) => o.type == "ball", + ) + const components = content.components.map((c) => + c.type == "player" + ? ({ + ...c, + hasBall: false, + } as Player) + : c, + ) + components.splice(ballObj, 1) return { ...content, - players: content.players.map((player) => ({ - ...player, - hasBall: false, - })), - objects: content.objects.toSpliced(ballObj, 1), + components, } }) setObjects([{ key: "ball" }]) @@ -423,7 +427,7 @@ function EditorView({ canDetach={(div) => isBoundsOnCourt(div.getBoundingClientRect()) } - onElementDetached={onPieceDetach} + onElementDetached={onRackPieceDetach} render={({ team, key }) => ( isBoundsOnCourt(div.getBoundingClientRect()) } - onElementDetached={onObjectDetach} + onElementDetached={onRackedObjectDetach} render={renderCourtObject} /> @@ -452,7 +456,7 @@ function EditorView({ canDetach={(div) => isBoundsOnCourt(div.getBoundingClientRect()) } - onElementDetached={onPieceDetach} + onElementDetached={onRackPieceDetach} render={({ team, key }) => (
} courtRef={courtDivContentRef} setActions={(actions) => setContent((content) => ({ ...content, - players: content.players, actions: actions(content.actions), })) } @@ -515,8 +517,8 @@ function EditorView({ } setContent((content) => ({ ...content, - players: toSplicedPlayers( - content.players, + components: replaceOrInsert( + content.components, player, true, ), @@ -533,10 +535,11 @@ function EditorView({ } function isBallOnCourt(content: TacticContent) { - if (content.players.findIndex((p) => p.hasBall) != -1) { - return true - } - return content.objects.findIndex((o) => o.type == "ball") != -1 + return ( + content.components.findIndex( + (c) => (c.type == "player" && c.hasBall) || c.type == BALL_TYPE, + ) != -1 + ) } function renderCourtObject(courtObject: RackedCourtObject) { @@ -558,12 +561,18 @@ function Court({ courtType }: { courtType: string }) { ) } -function getRackPlayers(team: PlayerTeam, players: Player[]): RackedPlayer[] { + +function getRackPlayers( + team: PlayerTeam, + components: TacticComponent[], +): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( (role) => - players.findIndex((p) => p.team == team && p.role == role) == - -1, + components.findIndex( + (c) => + c.type == "player" && c.team == team && c.role == role, + ) == -1, ) .map((key) => ({ team, key })) } @@ -611,14 +620,11 @@ function useContentState( return [content, setContentSynced, savingState] } -function toSplicedPlayers( - players: Player[], - player: Player, +function replaceOrInsert( + array: A[], + it: A, replace: boolean, -): Player[] { - const idx = players.findIndex( - (p) => p.team === player.team && p.role === player.role, - ) - - return players.toSpliced(idx, 1, ...(replace ? [player] : [])) +): A[] { + const idx = array.findIndex((i) => i.id == it.id) + return array.toSpliced(idx, 1, ...(replace ? [it] : [])) } diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index de33224..cef1e86 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -46,7 +46,7 @@ export function CourtAction({ }} wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants - endRadius={action.toPlayerId ? 26 : 17} + endRadius={action.toId ? 26 : 17} startRadius={0} onDeleteRequested={onActionDeleted} style={{ diff --git a/front/views/template/Header.tsx b/front/views/template/Header.tsx index 5c8cbcd..8555133 100644 --- a/front/views/template/Header.tsx +++ b/front/views/template/Header.tsx @@ -17,7 +17,7 @@ export function Header({ username }: { username: string }) { location.pathname = BASE + "/" }}> IQ - Ball + CourtObjects
diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 77f2b3d..0763818 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -26,7 +26,7 @@ CREATE TABLE Tactic name varchar NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, owner integer NOT NULL, - content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL, + content varchar DEFAULT '{"components": [], "actions": []}' NOT NULL, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL, FOREIGN KEY (owner) REFERENCES Account ); diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index 4bdcfae..b6ebd6e 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -42,7 +42,7 @@ class EditorController { return ViewHttpResponse::react("views/Editor.tsx", [ "id" => -1, //-1 id means that the editor will not support saves "name" => TacticModel::TACTIC_DEFAULT_NAME, - "content" => '{"players": [], "objects": [], "actions": []}', + "content" => '{"components": [], "actions": []}', "courtType" => $courtType->name(), ]); } diff --git a/src/App/Views/home.twig b/src/App/Views/home.twig index 0fc426a..2438ca1 100644 --- a/src/App/Views/home.twig +++ b/src/App/Views/home.twig @@ -52,7 +52,7 @@
-

IQ Ball

+

IQ CourtObjects

Date: Fri, 5 Jan 2024 19:33:46 +0100 Subject: [PATCH 21/44] add phantoms for move and dribble --- front/components/actions/BallAction.tsx | 16 +- front/components/arrows/BendableArrow.tsx | 2 +- front/components/editor/BallPiece.tsx | 4 +- front/components/editor/BasketCourt.tsx | 250 +------ front/components/editor/CourtBall.tsx | 4 +- front/components/editor/CourtPlayer.tsx | 59 +- front/editor/ActionsDomains.ts | 214 ++++++ front/editor/PlayerDomains.ts | 80 +++ front/editor/RackedItems.ts | 11 + front/editor/TacticContentDomains.ts | 299 ++++++++ front/model/tactic/Action.ts | 3 +- .../model/tactic/{Ball.ts => CourtObjects.ts} | 0 front/model/tactic/Player.ts | 49 +- front/model/tactic/Tactic.ts | 12 +- front/style/player.css | 4 + front/views/Editor.tsx | 653 +++++++++--------- front/views/editor/CourtAction.tsx | 2 +- sql/database.php | 2 +- 18 files changed, 1039 insertions(+), 625 deletions(-) create mode 100644 front/editor/ActionsDomains.ts create mode 100644 front/editor/PlayerDomains.ts create mode 100644 front/editor/RackedItems.ts create mode 100644 front/editor/TacticContentDomains.ts rename front/model/tactic/{Ball.ts => CourtObjects.ts} (100%) diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index a26785c..f4af373 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,17 +1,21 @@ -import { BallPiece } from "../editor/BallPiece" +import {BallPiece} from "../editor/BallPiece" import Draggable from "react-draggable" -import { useRef } from "react" +import {useRef} from "react" +import {NULL_POS} from "../../geo/Pos"; export interface BallActionProps { - onDrop: (el: HTMLElement) => void + onDrop: (el: DOMRect) => void } -export default function BallAction({ onDrop }: BallActionProps) { +export default function BallAction({onDrop}: BallActionProps) { const ref = useRef(null) return ( - onDrop(ref.current!)} nodeRef={ref}> + onDrop(ref.current!.getBoundingClientRect())} + position={NULL_POS}>
- +
) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index e2219bb..e46fb7b 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -147,7 +147,7 @@ export default function BendableArrow({ // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) - }, [startPos, segments, computeInternalSegments]) + }, [computeInternalSegments]) const [isSelected, setIsSelected] = useState(false) diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index d72ad75..1156780 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,8 +1,8 @@ import "../../style/ball.css" import BallSvg from "../../assets/icon/ball.svg?react" -import {BALL_ID} from "../../model/tactic/Ball"; +import { BALL_ID } from "../../model/tactic/CourtObjects" export function BallPiece() { - return + return } diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 7aba76c..f684e1b 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,37 +1,16 @@ -import { CourtBall } from "./CourtBall" +import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState,} from "react" +import {Action} from "../../model/tactic/Action" -import { - ReactElement, - RefObject, - useCallback, - useLayoutEffect, - useState, -} from "react" -import CourtPlayer from "./CourtPlayer" - -import { Player } from "../../model/tactic/Player" -import { Action, ActionKind } from "../../model/tactic/Action" -import ArrowAction from "../actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../../geo/Pos" -import BallAction from "../actions/BallAction" -import {BALL_ID} from "../../model/tactic/Ball" -import { contains, overlaps } from "../../geo/Box" - -import { CourtAction } from "../../views/editor/CourtAction" -import { TacticComponent } from "../../model/tactic/Tactic" +import {CourtAction} from "../../views/editor/CourtAction" +import {TacticComponent} from "../../model/tactic/Tactic" export interface BasketCourtProps { components: TacticComponent[] actions: Action[] + previewAction: Action | null - renderAction: (a: Action, key: number) => ReactElement - setActions: (f: (a: Action[]) => Action[]) => void - - onPlayerRemove: (p: Player) => void - onPlayerChange: (p: Player) => void - - onBallRemove: () => void - onBallMoved: (ball: DOMRect) => void + renderComponent: (comp: TacticComponent) => ReactNode + renderAction: (action: Action, idx: number) => ReactNode courtImage: ReactElement courtRef: RefObject @@ -40,104 +19,14 @@ export interface BasketCourtProps { export function BasketCourt({ components, actions, - renderAction, - setActions, - onPlayerRemove, - onPlayerChange, + previewAction, - onBallMoved, - onBallRemove, + renderComponent, + renderAction, courtImage, courtRef, }: BasketCourtProps) { - function placeArrow(origin: Player, arrowHead: DOMRect) { - const originRef = document.getElementById(origin.id)! - const courtBounds = courtRef.current!.getBoundingClientRect() - const start = ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ) - - for (const component of components) { - if (component.id == origin.id) { - continue - } - - const playerBounds = document - .getElementById(component.id)! - .getBoundingClientRect() - - if (overlaps(playerBounds, arrowHead)) { - const targetPos = document - .getElementById(component.id)! - .getBoundingClientRect() - - const end = ratioWithinBase(middlePos(targetPos), courtBounds) - - const action: Action = { - fromId: originRef.id, - toId: component.id, - type: - component.type == "player" - ? origin.hasBall - ? ActionKind.SHOOT - : ActionKind.SCREEN - : ActionKind.MOVE, - moveFrom: start, - segments: [{ next: end }], - } - setActions((actions) => [...actions, action]) - return - } - } - - const action: Action = { - fromId: originRef.id, - type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE, - moveFrom: ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ), - segments: [ - { next: ratioWithinBase(middlePos(arrowHead), courtBounds) }, - ], - } - setActions((actions) => [...actions, action]) - } - - const [previewAction, setPreviewAction] = useState(null) - - const updateActionsRelatedTo = useCallback((comp: TacticComponent) => { - const newPos = ratioWithinBase( - middlePos( - document.getElementById(comp.id)!.getBoundingClientRect(), - ), - courtRef.current!.getBoundingClientRect(), - ) - setActions((actions) => - actions.map((a) => { - if (a.fromId == comp.id) { - return { ...a, moveFrom: newPos } - } - - if (a.toId == comp.id) { - const segments = a.segments.toSpliced( - a.segments.length - 1, - 1, - { - ...a.segments[a.segments.length - 1], - next: newPos, - }, - ) - return { ...a, segments } - } - - return a - }), - ) - }, []) - const [internActions, setInternActions] = useState([]) useLayoutEffect(() => setInternActions(actions), [actions]) @@ -149,122 +38,7 @@ export function BasketCourt({ style={{ position: "relative" }}> {courtImage} - {components.map((component) => { - if (component.type == "player") { - const player = component - return ( - updateActionsRelatedTo(player)} - onChange={onPlayerChange} - onRemove={() => onPlayerRemove(player)} - courtRef={courtRef} - availableActions={(pieceRef) => [ - { - const baseBounds = - courtRef.current!.getBoundingClientRect() - - const arrowHeadPos = middlePos(headPos) - - const target = components.find( - (c) => - c.id != player.id && - contains( - document - .getElementById(c.id)! - .getBoundingClientRect(), - arrowHeadPos, - ), - ) - - const type = - target?.type == "player" - ? player.hasBall - ? target - ? ActionKind.SHOOT - : ActionKind.DRIBBLE - : target - ? ActionKind.SCREEN - : ActionKind.MOVE - : ActionKind.MOVE - - setPreviewAction((action) => ({ - ...action!, - segments: [ - { - next: ratioWithinBase( - arrowHeadPos, - baseBounds, - ), - }, - ], - type, - })) - }} - onHeadPicked={(headPos) => { - ;( - document.activeElement as HTMLElement - ).blur() - const baseBounds = - courtRef.current!.getBoundingClientRect() - - setPreviewAction({ - type: player.hasBall - ? ActionKind.DRIBBLE - : ActionKind.MOVE, - fromId: player.id, - toId: undefined, - moveFrom: ratioWithinBase( - middlePos( - pieceRef.getBoundingClientRect(), - ), - baseBounds, - ), - segments: [ - { - next: ratioWithinBase( - middlePos(headPos), - baseBounds, - ), - }, - ], - }) - }} - onHeadDropped={(headRect) => { - placeArrow(player, headRect) - setPreviewAction(null) - }} - />, - player.hasBall && ( - - onBallMoved( - ref.getBoundingClientRect(), - ) - } - /> - ), - ]} - /> - ) - } - if (component.type == BALL_ID) { - return ( - updateActionsRelatedTo(component)} - ball={component} - onRemove={onBallRemove} - key="ball" - /> - ) - } - throw new Error("unknown tactic component " + component) - })} + {components.map(renderComponent)} {internActions.map((action, idx) => renderAction(action, idx))} @@ -272,7 +46,7 @@ export function BasketCourt({ {}} onActionChanges={() => {}} /> diff --git a/front/components/editor/CourtBall.tsx b/front/components/editor/CourtBall.tsx index 1e208be..53ae408 100644 --- a/front/components/editor/CourtBall.tsx +++ b/front/components/editor/CourtBall.tsx @@ -1,7 +1,8 @@ import React, { useRef } from "react" import Draggable from "react-draggable" import { BallPiece } from "./BallPiece" -import { Ball } from "../../model/tactic/Ball" +import { NULL_POS } from "../../geo/Pos" +import { Ball } from "../../model/tactic/CourtObjects" export interface CourtBallProps { onPosValidated: (rect: DOMRect) => void @@ -27,6 +28,7 @@ export function CourtBall({ onPosValidated(pieceRef.current!.getBoundingClientRect()) } onDrag={onMoves} + position={NULL_POS} nodeRef={pieceRef}>
void - onChange: (p: Player) => void +export interface CourtPlayerProps { + playerInfo: PlayerInfo + className?: string + + onMoves: () => void + onPositionValidated: (newPos: Pos) => void onRemove: () => void courtRef: RefObject availableActions: (ro: HTMLElement) => ReactNode[] @@ -18,45 +20,38 @@ export interface PlayerProps { * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ export default function CourtPlayer({ - player, - onDrag, - onChange, + playerInfo, + className, + + onMoves, + onPositionValidated, onRemove, courtRef, availableActions, -}: PlayerProps) { - const hasBall = player.hasBall - const x = player.rightRatio - const y = player.bottomRatio +}: CourtPlayerProps) { + const usesBall = playerInfo.ballState != BallState.NONE + const x = playerInfo.rightRatio + const y = playerInfo.bottomRatio const pieceRef = useRef(null) return ( { const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect() - const { x, y } = ratioWithinBase(pieceBounds, parentBounds) - - onChange({ - type: "player", - id: player.id, - rightRatio: x, - bottomRatio: y, - team: player.team, - role: player.role, - hasBall: player.hasBall, - } as Player) + const pos = ratioWithinBase(pieceBounds, parentBounds) + onPositionValidated(pos) }}>
diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts new file mode 100644 index 0000000..1179dc7 --- /dev/null +++ b/front/editor/ActionsDomains.ts @@ -0,0 +1,214 @@ +import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" +import {middlePos, ratioWithinBase} from "../geo/Pos" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {Action, ActionKind} from "../model/tactic/Action" +import {removeBall, updateComponent} from "./TacticContentDomains" +import {getOrigin} from "./PlayerDomains" + +export function refreshAllActions( + actions: Action[], + components: TacticComponent[], +) { + return actions.map((action) => ({ + ...action, + type: getActionKindFrom(action.fromId, action.toId, components), + })) +} + +export function getActionKindFrom( + originId: ComponentId, + targetId: ComponentId | null, + components: TacticComponent[], +): ActionKind { + const origin = components.find((p) => p.id == originId)! + const target = components.find(p => p.id == targetId) + + let ballState = BallState.NONE + + if (origin.type == "player" || origin.type == "phantom") { + ballState = origin.ballState + } + + let hasTarget = target ? (target.type != 'phantom' || target.originPlayerId != origin.id) : false + + return getActionKind(hasTarget, ballState) +} + +export function getActionKind(hasTarget: boolean, ballState: BallState): ActionKind { + switch (ballState) { + case BallState.HOLDS: + return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE + case BallState.SHOOTED: + return ActionKind.MOVE + case BallState.NONE: + return hasTarget ? ActionKind.SCREEN : ActionKind.MOVE + } +} + +export function placeArrow( + origin: Player | PlayerPhantom, + courtBounds: DOMRect, + arrowHead: DOMRect, + content: TacticContent, +): { createdAction: Action, newContent: TacticContent } { + const originRef = document.getElementById(origin.id)! + const start = ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ) + + /** + * Creates a new phantom component. + * Be aware that this function will reassign the `content` parameter. + * @param receivesBall + */ + function createPhantom(receivesBall: boolean): ComponentId { + const {x, y} = ratioWithinBase(arrowHead, courtBounds) + + let itemIndex: number + let originPlayer: Player + + if (origin.type == "phantom") { + // if we create a phantom from another phantom, + // simply add it to the phantom's path + const originPlr = getOrigin(origin, content.components)! + itemIndex = originPlr.path!.items.length + originPlayer = originPlr + } else { + // if we create a phantom directly from a player + // create a new path and add it into + itemIndex = 0 + originPlayer = origin + } + + const path = originPlayer.path + + const phantomId = "phantom-" + itemIndex + "-" + originPlayer.id + + content = updateComponent( + { + ...originPlayer, + path: { + items: path ? [...path.items, phantomId] : [phantomId], + }, + }, + content, + ) + + const ballState = receivesBall + ? BallState.HOLDS + : origin.ballState == BallState.HOLDS + ? BallState.HOLDS + : BallState.NONE + + const phantom: PlayerPhantom = { + type: "phantom", + id: phantomId, + rightRatio: x, + bottomRatio: y, + originPlayerId: originPlayer.id, + ballState + } + content = { + ...content, + components: [...content.components, phantom], + } + return phantom.id + } + + for (const component of content.components) { + if (component.id == origin.id) { + continue + } + + const componentBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(componentBounds, arrowHead)) { + const targetPos = document + .getElementById(component.id)! + .getBoundingClientRect() + + const end = ratioWithinBase(middlePos(targetPos), courtBounds) + + let toId = component.id + + if (component.type == "ball") { + toId = createPhantom(true) + content = removeBall(content) + } + + const action: Action = { + fromId: originRef.id, + toId, + type: getActionKind(true, origin.ballState), + moveFrom: start, + segments: [{next: end}], + } + + return { + newContent: { + ...content, + actions: [...content.actions, action], + }, + createdAction: action + } + } + } + + const phantomId = createPhantom(origin.ballState == BallState.HOLDS) + + const action: Action = { + fromId: originRef.id, + toId: phantomId, + type: getActionKind(false, origin.ballState), + moveFrom: ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ), + segments: [ + {next: ratioWithinBase(middlePos(arrowHead), courtBounds)}, + ], + } + return { + newContent: { + ...content, + actions: [...content.actions, action], + }, + createdAction: action + } +} + +export function repositionActionsRelatedTo( + compId: ComponentId, + courtBounds: DOMRect, + actions: Action[], +): Action[] { + const posRect = document.getElementById(compId)?.getBoundingClientRect() + const newPos = posRect != undefined + ? ratioWithinBase(middlePos(posRect), courtBounds) + : undefined + + return actions.flatMap((action) => { + if (newPos == undefined) { + return [] + } + + if (action.fromId == compId) { + return [{...action, moveFrom: newPos}] + } + + if (action.toId == compId) { + const lastIdx = action.segments.length - 1 + const segments = action.segments.toSpliced(lastIdx, 1, { + ...action.segments[lastIdx], + next: newPos!, + }) + return [{...action, segments}] + } + + return action + }) +} diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts new file mode 100644 index 0000000..9ef1d45 --- /dev/null +++ b/front/editor/PlayerDomains.ts @@ -0,0 +1,80 @@ +import { Player, PlayerPhantom } from "../model/tactic/Player" +import { TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { removeComponent, updateComponent } from "./TacticContentDomains" + +export function getOrigin( + pathItem: PlayerPhantom, + components: TacticComponent[], +): Player { + // Trust the components to contains only phantoms with valid player origin identifiers + return components.find((c) => c.id == pathItem.originPlayerId)! as Player +} + +export function removePlayerPath( + player: Player, + content: TacticContent, +): TacticContent { + if (player.path == null) { + return content + } + + for (const pathElement of player.path.items) { + content = removeComponent(pathElement, content) + } + return updateComponent( + { + ...player, + path: null, + }, + content, + ) +} + +export function removePlayer( + player: Player | PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.type == "phantom") { + const origin = getOrigin(player, content.components) + return truncatePlayerPath(origin, player, content) + } + + content = removePlayerPath(player, content) + return removeComponent(player.id, content) +} + +export function truncatePlayerPath( + player: Player, + phantom: PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.path == null) return content + + const path = player.path! + + let truncateStartIdx = -1 + + for (let j = 0; j < path.items.length; j++) { + const pathPhantomId = path.items[j] + if (truncateStartIdx != -1 || pathPhantomId == phantom.id) { + if (truncateStartIdx == -1) truncateStartIdx = j + + //remove the phantom from the tactic + content = removeComponent(pathPhantomId, content) + } + } + + return updateComponent( + { + ...player, + path: + truncateStartIdx == 0 + ? null + : { + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, + }, + content, + ) +} diff --git a/front/editor/RackedItems.ts b/front/editor/RackedItems.ts new file mode 100644 index 0000000..f2df151 --- /dev/null +++ b/front/editor/RackedItems.ts @@ -0,0 +1,11 @@ +/** + * information about a player that is into a rack + */ +import { PlayerTeam } from "../model/tactic/Player" + +export interface RackedPlayer { + team: PlayerTeam + key: string +} + +export type RackedCourtObject = { key: "ball" } diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts new file mode 100644 index 0000000..bec65bc --- /dev/null +++ b/front/editor/TacticContentDomains.ts @@ -0,0 +1,299 @@ +import {Pos, ratioWithinBase} from "../geo/Pos" +import {BallState, Player, PlayerInfo, PlayerTeam} from "../model/tactic/Player" +import {Ball, BALL_ID, BALL_TYPE, CourtObject} from "../model/tactic/CourtObjects" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {RackedCourtObject, RackedPlayer} from "./RackedItems" +import {refreshAllActions} from "./ActionsDomains" +import {getOrigin} from "./PlayerDomains"; + +export function placePlayerAt( + refBounds: DOMRect, + courtBounds: DOMRect, + element: RackedPlayer, +): Player { + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + return { + type: "player", + id: "player-" + element.key + "-" + element.team, + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + ballState: BallState.NONE, + path: null, + } +} + +export function placeObjectAt( + refBounds: DOMRect, + courtBounds: DOMRect, + rackedObject: RackedCourtObject, + content: TacticContent, +): TacticContent { + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + let courtObject: CourtObject + + switch (rackedObject.key) { + case BALL_TYPE: + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content) + } + + courtObject = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + } + break + + default: + throw new Error("unknown court object " + rackedObject.key) + } + + return { + ...content, + components: [...content.components, courtObject], + } +} + +export function dropBallOnComponent( + targetedComponentIdx: number, + content: TacticContent, +): TacticContent { + let components = content.components + let component = components[targetedComponentIdx] + + let origin + let isPhantom: boolean + + if (component.type == 'phantom') { + isPhantom = true + origin = getOrigin(component, components) + } else if (component.type == 'player') { + isPhantom = false + origin = component + } else { + return content + } + + components = components.toSpliced(targetedComponentIdx, 1, { + ...component, + ballState: BallState.HOLDS, + }) + if (origin.path != null) { + const phantoms = origin.path!.items + const headingPhantoms = isPhantom ? phantoms.slice(phantoms.indexOf(component.id)) : phantoms + components = components.map(c => headingPhantoms.indexOf(c.id) != -1 ? { + ...c, + hasBall: true + } : c) + } + + const ballObj = components.findIndex((p) => p.type == BALL_TYPE) + + // Maybe the ball is not present on the court as an object component + // if so, don't bother removing it from the court. + // This can occur if the user drags and drop the ball from a player that already has the ball + // to another component + if (ballObj != -1) { + components.splice(ballObj, 1) + } + return { + ...content, + actions: refreshAllActions(content.actions, components), + components, + } +} + +export function removeBall(content: TacticContent): TacticContent { + const ballObj = content.components.findIndex((o) => o.type == "ball") + + const components = content.components.map((c) => + (c.type == 'player' || c.type == 'phantom') + ? { + ...c, + hasBall: false, + } + : c, + ) + + // if the ball is already not on the court, do nothing + if (ballObj != -1) { + components.splice(ballObj, 1) + } + + return { + ...content, + actions: refreshAllActions(content.actions, components), + components, + } +} + +export function placeBallAt( + refBounds: DOMRect, + courtBounds: DOMRect, + content: TacticContent, +): { + newContent: TacticContent + removed: boolean +} { + if (!overlaps(courtBounds, refBounds)) { + return {newContent: removeBall(content), removed: true} + } + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return { + newContent: dropBallOnComponent(playerCollidedIdx, { + ...content, + components: content.components.map((c) => + c.type == "player" || c.type == 'phantom' + ? { + ...c, + hasBall: false, + } + : c, + ), + }), + removed: false, + } + } + + const ballIdx = content.components.findIndex((o) => o.type == "ball") + + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + const components = content.components.map((c) => + c.type == "player" || c.type == "phantom" + ? { + ...c, + hasBall: false, + } + : c, + ) + + const ball: Ball = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + } + if (ballIdx != -1) { + components.splice(ballIdx, 1, ball) + } else { + components.push(ball) + } + + return { + newContent: { + ...content, + actions: refreshAllActions(content.actions, components), + components, + }, + removed: false, + } +} + +export function moveComponent( + newPos: Pos, + component: TacticComponent, + info: PlayerInfo, + courtBounds: DOMRect, + content: TacticContent, + removed: (content: TacticContent) => TacticContent, +): TacticContent { + const playerBounds = document + .getElementById(info.id)! + .getBoundingClientRect() + + // if the piece is no longer on the court, remove it + if (!overlaps(playerBounds, courtBounds)) { + return removed(content) + } + return updateComponent( + { + ...component, + rightRatio: newPos.x, + bottomRatio: newPos.y, + }, + content, + ) +} + +export function removeComponent( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + const componentIdx = content.components.findIndex( + (c) => c.id == componentId, + ) + + return { + ...content, + components: content.components.toSpliced(componentIdx, 1), + actions: content.actions.filter( + (a) => a.toId !== componentId && a.fromId !== componentId, + ), + } +} + +export function updateComponent( + component: TacticComponent, + content: TacticContent, +): TacticContent { + const componentIdx = content.components.findIndex( + (c) => c.id == component.id, + ) + return { + ...content, + components: content.components.toSpliced(componentIdx, 1, component), + } +} + +export function getComponentCollided( + bounds: DOMRect, + components: TacticComponent[], + ignore?: ComponentId, +): number | -1 { + for (let i = 0; i < components.length; i++) { + const component = components[i] + + if (component.id == ignore) continue + + const playerBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(playerBounds, bounds)) { + return i + } + } + return -1 +} + +export function getRackPlayers( + team: PlayerTeam, + components: TacticComponent[], +): RackedPlayer[] { + return ["1", "2", "3", "4", "5"] + .filter( + (role) => + components.findIndex( + (c) => + c.type == "player" && c.team == team && c.role == role, + ) == -1, + ) + .map((key) => ({team, key})) +} diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index d238398..f22dfaf 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -1,4 +1,3 @@ - import { Pos } from "../../geo/Pos" import { Segment } from "../../components/arrows/BendableArrow" import { ComponentId } from "./Tactic" @@ -14,7 +13,7 @@ export type Action = { type: ActionKind } & MovementAction export interface MovementAction { fromId: ComponentId - toId?: ComponentId + toId: ComponentId | null moveFrom: Pos segments: Segment[] } diff --git a/front/model/tactic/Ball.ts b/front/model/tactic/CourtObjects.ts similarity index 100% rename from front/model/tactic/Ball.ts rename to front/model/tactic/CourtObjects.ts diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index e558496..7df59ec 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -1,4 +1,4 @@ -import {Component} from "./Tactic"; +import { Component, ComponentId } from "./Tactic" export type PlayerId = string @@ -7,11 +7,15 @@ export enum PlayerTeam { Opponents = "opponents", } -export interface Player { +export interface Player extends PlayerInfo, Component<"player"> { readonly id: PlayerId } -export interface Player extends Component<"player"> { +/** + * All information about a player + */ +export interface PlayerInfo { + readonly id: string /** * the player's team * */ @@ -25,6 +29,43 @@ export interface Player extends Component<"player"> { /** * True if the player has a basketball */ - readonly hasBall: boolean + readonly ballState: BallState + + /** + * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + */ + readonly bottomRatio: number + + /** + * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number +} + +export enum BallState { + NONE, + HOLDS, + SHOOTED } +export interface Player extends Component<"player">, PlayerInfo { + /** + * True if the player has a basketball + */ + readonly ballState: BallState + + readonly path: MovementPath | null +} + +export interface MovementPath { + readonly items: ComponentId[] +} + +/** + * A player phantom is a kind of component that represents the future state of a player + * according to the court's step information + */ +export interface PlayerPhantom extends Component<"phantom"> { + readonly originPlayerId: ComponentId + readonly ballState: BallState +} diff --git a/front/model/tactic/Tactic.ts b/front/model/tactic/Tactic.ts index 6580dbb..c641ac4 100644 --- a/front/model/tactic/Tactic.ts +++ b/front/model/tactic/Tactic.ts @@ -1,6 +1,6 @@ -import {Player} from "./Player" -import {Action} from "./Action" -import {CourtObject} from "./Ball" +import { Player, PlayerPhantom } from "./Player" +import { Action } from "./Action" +import { CourtObject } from "./CourtObjects" export interface Tactic { id: number @@ -13,7 +13,7 @@ export interface TacticContent { actions: Action[] } -export type TacticComponent = Player | CourtObject +export type TacticComponent = Player | CourtObject | PlayerPhantom export type ComponentId = string export interface Component { @@ -26,12 +26,12 @@ export interface Component { */ readonly id: ComponentId /** - * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + * Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) */ readonly bottomRatio: number /** - * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) + * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) */ readonly rightRatio: number } diff --git a/front/style/player.css b/front/style/player.css index 22afe4e..b03123b 100644 --- a/front/style/player.css +++ b/front/style/player.css @@ -2,6 +2,10 @@ pointer-events: none; } +.phantom { + opacity: 50%; +} + .player-content { display: flex; flex-direction: column; diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 387ad74..5163bcc 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -8,20 +8,37 @@ import {BallPiece} from "../components/editor/BallPiece" import {Rack} from "../components/Rack" import {PlayerPiece} from "../components/editor/PlayerPiece" -import {Player, PlayerTeam} from "../model/tactic/Player" import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" import {fetchAPI} from "../Fetcher" import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" -import {BALL_ID, BALL_TYPE, CourtObject, Ball} from "../model/tactic/Ball" +import {BALL_TYPE} from "../model/tactic/CourtObjects" import {CourtAction} from "./editor/CourtAction" import {BasketCourt} from "../components/editor/BasketCourt" +import {overlaps} from "../geo/Box" +import { + dropBallOnComponent, + getComponentCollided, + getRackPlayers, + moveComponent, + placeBallAt, + placeObjectAt, + placePlayerAt, + removeBall, updateComponent, +} from "../editor/TacticContentDomains" +import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" +import {RackedCourtObject} from "../editor/RackedItems" +import CourtPlayer from "../components/editor/CourtPlayer" +import {getActionKind, placeArrow, repositionActionsRelatedTo,} from "../editor/ActionsDomains" +import ArrowAction from "../components/actions/ArrowAction" +import {middlePos, ratioWithinBase} from "../geo/Pos" import {Action, ActionKind} from "../model/tactic/Action" +import BallAction from "../components/actions/BallAction" +import {getOrigin, removePlayer, truncatePlayerPath,} from "../editor/PlayerDomains" +import {CourtBall} from "../components/editor/CourtBall" import {BASE} from "../Constants" -import {overlaps} from "../geo/Box" -import {ratioWithinBase} from "../geo/Pos" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -44,17 +61,7 @@ export interface EditorProps { courtType: "PLAIN" | "HALF" } -/** - * information about a player that is into a rack - */ -interface RackedPlayer { - team: PlayerTeam - key: string -} - -type RackedCourtObject = { key: "ball" } - -export default function Editor({ id, name, courtType, content }: EditorProps) { +export default function Editor({id, name, courtType, content}: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) @@ -80,7 +87,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { ) return SaveStates.Guest } - return fetchAPI(`tactic/${id}/save`, { content }).then((r) => + return fetchAPI(`tactic/${id}/save`, {content}).then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} @@ -89,7 +96,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { 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( + return fetchAPI(`tactic/${id}/edit/name`, {name}).then( (r) => r.ok, ) }} @@ -99,11 +106,11 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { } function EditorView({ - tactic: { id, name, content: initialContent }, - onContentChange, - onNameChange, - courtType, -}: EditorViewProps) { + tactic: {id, name, content: initialContent}, + onContentChange, + onNameChange, + courtType, + }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -124,235 +131,36 @@ function EditorView({ ) const [allies, setAllies] = useState( - getRackPlayers(PlayerTeam.Allies, content.components), + () => getRackPlayers(PlayerTeam.Allies, content.components), ) const [opponents, setOpponents] = useState( - getRackPlayers(PlayerTeam.Opponents, content.components), + () => getRackPlayers(PlayerTeam.Opponents, content.components), ) const [objects, setObjects] = useState( - isBallOnCourt(content) ? [] : [{ key: "ball" }], + () => isBallOnCourt(content) ? [] : [{key: "ball"}], ) - const courtDivContentRef = useRef(null) - - const isBoundsOnCourt = (bounds: DOMRect) => { - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - - // check if refBounds overlaps courtBounds - return overlaps(courtBounds, bounds) - } - - const onRackPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { - const refBounds = ref.getBoundingClientRect() - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - - const { x, y } = ratioWithinBase(refBounds, courtBounds) - - setContent((content) => { - return { - ...content, - components: [ - ...content.components, - { - type: "player", - id: "player-" + element.key + "-" + element.team, - team: element.team, - role: element.key, - rightRatio: x, - bottomRatio: y, - hasBall: false, - } as Player, - ], - actions: content.actions, - } - }) - } - - const onRackedObjectDetach = ( - ref: HTMLDivElement, - rackedObject: RackedCourtObject, - ) => { - const refBounds = ref.getBoundingClientRect() - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const [previewAction, setPreviewAction] = useState(null) - let courtObject: CourtObject - - switch (rackedObject.key) { - case BALL_TYPE: - const ballObj = content.components.findIndex( - (o) => o.type == BALL_TYPE, - ) - const playerCollidedIdx = getComponentCollided( - refBounds, - content.components.toSpliced(ballObj, 1), - ) - if (playerCollidedIdx != -1) { - onBallDropOnComponent(playerCollidedIdx) - return - } + const courtRef = useRef(null) - courtObject = { - type: BALL_TYPE, - id: BALL_ID, - rightRatio: x, - bottomRatio: y, - } - break - - default: - throw new Error("unknown court object " + rackedObject.key) - } - - setContent((content) => { - return { - ...content, - components: [...content.components, courtObject], - } - }) - } - - const getComponentCollided = ( - bounds: DOMRect, - components: TacticComponent[], - ): number | -1 => { - for (let i = 0; i < components.length; i++) { - const component = components[i] - const playerBounds = document - .getElementById(component.id)! - .getBoundingClientRect() - if (overlaps(playerBounds, bounds)) { - return i - } - } - return -1 - } - - function updateActions(actions: Action[], components: TacticComponent[]) { - return actions.map((action) => { - const originHasBall = ( - components.find( - (p) => p.type == "player" && p.id == action.fromId, - )! as Player - ).hasBall - - let type = action.type - - if (originHasBall && type == ActionKind.MOVE) { - type = ActionKind.DRIBBLE - } else if (originHasBall && type == ActionKind.SCREEN) { - type = ActionKind.SHOOT - } else if (type == ActionKind.DRIBBLE) { - type = ActionKind.MOVE - } else if (type == ActionKind.SHOOT) { - type = ActionKind.SCREEN - } - return { - ...action, - type, - } - }) - } - - const onBallDropOnComponent = (collidedComponentIdx: number) => { - setContent((content) => { - const ballObj = content.components.findIndex( - (p) => p.type == BALL_TYPE, - ) - let component = content.components[collidedComponentIdx] - if (component.type != "player") { - return content //do nothing if the ball isn't dropped on a player. - } - const components = content.components.toSpliced( - collidedComponentIdx, - 1, - { - ...component, - hasBall: true, - }, - ) - // Maybe the ball is not present on the court as an object component - // if so, don't bother removing it from the court. - // This can occur if the user drags and drop the ball from a player that already has the ball - // to another component - if (ballObj != -1) { - components.splice(ballObj, 1) - } - return { - ...content, - actions: updateActions(content.actions, components), - components, - } - }) + const setActions = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + actions: typeof action == "function" ? action(c.actions) : action, + })) } - const onBallMoved = (refBounds: DOMRect) => { - if (!isBoundsOnCourt(refBounds)) { - removeCourtBall() - return - } - const playerCollidedIdx = getComponentCollided( - refBounds, - content.components, - ) - if (playerCollidedIdx != -1) { - setContent((content) => { - return { - ...content, - components: content.components.map((c) => - c.type == "player" - ? { - ...c, - hasBall: false, - } - : c, - ), - } - }) - onBallDropOnComponent(playerCollidedIdx) - return - } - - if (content.components.findIndex((o) => o.type == "ball") != -1) { - return - } - - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = ratioWithinBase(refBounds, courtBounds) - const courtObject = { - type: BALL_TYPE, - id: BALL_ID, - rightRatio: x, - bottomRatio: y, - } as Ball - - let components = content.components.map((c) => - c.type == "player" - ? { - ...c, - hasBall: false, - } - : c, - ) - components = [...components, courtObject] - - setContent((content) => ({ - ...content, - actions: updateActions(content.actions, components), - components, + const setComponents = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + components: + typeof action == "function" ? action(c.components) : action, })) } - const removePlayer = (player: Player) => { - setContent((content) => ({ - ...content, - components: replaceOrInsert(content.components, player, false), - actions: content.actions.filter( - (a) => a.toId !== player.id && a.fromId !== player.id, - ), - })) + const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { case PlayerTeam.Opponents: @@ -361,8 +169,8 @@ function EditorView({ case PlayerTeam.Allies: setter = setAllies } - if (player.hasBall) { - setObjects([{ key: "ball" }]) + if (player.ballState == BallState.HOLDS) { + setObjects([{key: "ball"}]) } setter((players) => [ ...players, @@ -374,26 +182,168 @@ function EditorView({ ]) } - const removeCourtBall = () => { + const doMoveBall = (newBounds: DOMRect) => { setContent((content) => { - const ballObj = content.components.findIndex( - (o) => o.type == "ball", + const {newContent, removed} = placeBallAt( + newBounds, + courtBounds(), + content, ) - const components = content.components.map((c) => - c.type == "player" - ? ({ - ...c, - hasBall: false, - } as Player) - : c, - ) - components.splice(ballObj, 1) - return { - ...content, - components, + + if (removed) { + setObjects((objects) => [...objects, {key: "ball"}]) } + + return newContent }) - setObjects([{ key: "ball" }]) + } + + const courtBounds = () => courtRef.current!.getBoundingClientRect() + + const renderPlayer = (component: Player | PlayerPhantom) => { + let info: PlayerInfo + let canPlaceArrows: boolean + const isPhantom = component.type == "phantom" + + if (isPhantom) { + const origin = getOrigin(component, content.components) + const path = origin.path! + // phantoms can only place other arrows if they are the head of the path + canPlaceArrows = path.items.indexOf(component.id) == path.items.length - 1 + if (canPlaceArrows) { + // and if their only action is to shoot the ball + + // list the actions the phantoms does + const phantomArrows = content.actions.filter(c => c.fromId == component.id) + canPlaceArrows = phantomArrows.length == 0 || phantomArrows.findIndex(c => c.type != ActionKind.SHOOT) == -1 + } + + info = { + id: component.id, + team: origin.team, + role: origin.role, + bottomRatio: component.bottomRatio, + rightRatio: component.rightRatio, + ballState: component.ballState, + } + } else { + // a player + info = component + // can place arrows only if the + canPlaceArrows = component.path == null || content.actions.findIndex(p => p.fromId == component.id && p.type != ActionKind.SHOOT) == -1 + } + + return ( + + setActions((actions) => + repositionActionsRelatedTo(info.id, courtBounds(), actions), + ) + } + onPositionValidated={(newPos) => { + setContent((content) => + moveComponent( + newPos, + component, + info, + courtBounds(), + content, + + (content) => { + if (!isPhantom) insertRackedPlayer(component) + return removePlayer(component, content) + }, + ), + ) + }} + onRemove={() => { + setContent((c) => removePlayer(component, c)) + if (!isPhantom) insertRackedPlayer(component) + }} + courtRef={courtRef} + availableActions={(pieceRef) => [ + canPlaceArrows && ( + { + const arrowHeadPos = middlePos(headPos) + const targetIdx = getComponentCollided(headPos, content.components) + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase( + arrowHeadPos, + courtBounds(), + ), + }, + ], + type: getActionKind(targetIdx != -1, info.ballState), + })) + }} + onHeadPicked={(headPos) => { + (document.activeElement as HTMLElement).blur() + + setPreviewAction({ + type: getActionKind(false, info.ballState), + fromId: info.id, + toId: null, + moveFrom: ratioWithinBase( + middlePos( + pieceRef.getBoundingClientRect(), + ), + courtBounds(), + ), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + courtBounds(), + ), + }, + ], + }) + }} + onHeadDropped={(headRect) => { + setContent((content) => { + let {createdAction, newContent} = placeArrow( + component, + courtBounds(), + headRect, + content, + ) + + let originNewBallState = component.ballState + + if (createdAction.type == ActionKind.SHOOT) { + const targetIdx = newContent.components.findIndex(c => c.id == createdAction.toId) + newContent = dropBallOnComponent(targetIdx, newContent) + originNewBallState = BallState.SHOOTED + } + + newContent = updateComponent({ + ...(newContent.components.find(c => c.id == component.id)! as Player | PlayerPhantom), + ballState: originNewBallState + }, newContent) + return newContent + }) + setPreviewAction(null) + }} + /> + ), + info.ballState != BallState.NONE && ( + + ), + ]} + /> + ) } return ( @@ -403,7 +353,7 @@ function EditorView({ Home
- +
-
+
@@ -425,10 +375,19 @@ function EditorView({ objects={allies} onChange={setAllies} canDetach={(div) => - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) } - onElementDetached={onRackPieceDetach} - render={({ team, key }) => ( + onElementDetached={(r, e) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]) + } + render={({team, key}) => ( - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) + } + onElementDetached={(r, e) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ) } - onElementDetached={onRackedObjectDetach} render={renderCourtObject} /> @@ -454,10 +422,19 @@ function EditorView({ objects={opponents} onChange={setOpponents} canDetach={(div) => - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) } - onElementDetached={onRackPieceDetach} - render={({ team, key }) => ( + onElementDetached={(r, e) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]) + } + render={({team, key}) => ( } - courtRef={courtDivContentRef} - setActions={(actions) => - setContent((content) => ({ - ...content, - actions: actions(content.actions), - })) - } + courtImage={} + courtRef={courtRef} + previewAction={previewAction} + renderComponent={(component) => { + if ( + component.type == "player" || + component.type == "phantom" + ) { + return renderPlayer(component) + } + if (component.type == BALL_TYPE) { + return ( + + setActions((actions) => + repositionActionsRelatedTo( + component.id, + courtBounds(), + actions, + ), + ) + } + onRemove={() => { + setContent((content) => + removeBall(content), + ) + setObjects(objects => [...objects, {key: "ball"}]) + }} + /> + ) + } + throw new Error( + "unknown tactic component " + component, + ) + }} renderAction={(action, i) => ( { - setContent((content) => ({ - ...content, - actions: content.actions.toSpliced( - i, - 1, - ), - })) + setContent((content) => { + content = { + ...content, + actions: + content.actions.toSpliced( + i, + 1, + ), + } + + if (action.toId == null) + return content + + const target = + content.components.find( + (c) => action.toId == c.id, + )! + + if (target.type == "phantom") { + const origin = getOrigin( + target, + content.components, + ) + if (origin.id != action.fromId) { + return content + } + content = truncatePlayerPath( + origin, + target, + content, + ) + } + + return content + }) }} onActionChanges={(a) => setContent((content) => ({ @@ -507,25 +541,6 @@ function EditorView({ } /> )} - onPlayerChange={(player) => { - const playerBounds = document - .getElementById(player.id)! - .getBoundingClientRect() - if (!isBoundsOnCourt(playerBounds)) { - removePlayer(player) - return - } - setContent((content) => ({ - ...content, - components: replaceOrInsert( - content.components, - player, - true, - ), - })) - }} - onPlayerRemove={removePlayer} - onBallRemove={removeCourtBall} />
@@ -537,46 +552,30 @@ function EditorView({ function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( - (c) => (c.type == "player" && c.hasBall) || c.type == BALL_TYPE, + (c) => (c.type == "player" && c.ballState == BallState.HOLDS) || c.type == BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({ courtType }: { courtType: string }) { +function Court({courtType}: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) } - -function getRackPlayers( - team: PlayerTeam, - components: TacticComponent[], -): RackedPlayer[] { - return ["1", "2", "3", "4", "5"] - .filter( - (role) => - components.findIndex( - (c) => - c.type == "player" && c.team == team && c.role == role, - ) == -1, - ) - .map((key) => ({ team, key })) -} - function debounceAsync( f: (args: A) => Promise, delay = 1000, @@ -605,6 +604,7 @@ function useContentState( typeof newState === "function" ? (newState as (state: S) => S)(content) : newState + if (state !== content) { setSavingState(SaveStates.Saving) saveStateCallback(state) @@ -619,12 +619,3 @@ function useContentState( return [content, setContentSynced, savingState] } - -function replaceOrInsert
( - array: A[], - it: A, - replace: boolean, -): A[] { - const idx = array.findIndex((i) => i.id == it.id) - return array.toSpliced(idx, 1, ...(replace ? [it] : [])) -} diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index cef1e86..22a4147 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -47,7 +47,7 @@ export function CourtAction({ wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants endRadius={action.toId ? 26 : 17} - startRadius={0} + startRadius={10} onDeleteRequested={onActionDeleted} style={{ head, diff --git a/sql/database.php b/sql/database.php index 69b53e7..336416c 100644 --- a/sql/database.php +++ b/sql/database.php @@ -1,7 +1,7 @@ Date: Sun, 14 Jan 2024 15:50:55 +0100 Subject: [PATCH 22/44] store actions directly inside each components, enhance bendable arrows to hook to DOM elements --- front/components/actions/BallAction.tsx | 10 +- front/components/arrows/BendableArrow.tsx | 72 ++++- front/components/editor/BasketCourt.tsx | 37 ++- front/components/editor/CourtBall.tsx | 9 +- front/components/editor/CourtPlayer.tsx | 11 +- front/editor/ActionsDomains.ts | 160 +++++----- front/editor/PlayerDomains.ts | 9 +- front/editor/TacticContentDomains.ts | 90 +++--- front/model/tactic/Action.ts | 5 +- front/model/tactic/Player.ts | 2 +- front/model/tactic/Tactic.ts | 4 +- front/style/actions/arrow_action.css | 1 + front/views/Editor.tsx | 358 ++++++++++++---------- front/views/editor/CourtAction.tsx | 8 +- sql/setup-tables.sql | 2 +- src/App/Controller/EditorController.php | 2 +- 16 files changed, 436 insertions(+), 344 deletions(-) diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index f4af373..87779df 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,13 +1,13 @@ -import {BallPiece} from "../editor/BallPiece" +import { BallPiece } from "../editor/BallPiece" import Draggable from "react-draggable" -import {useRef} from "react" -import {NULL_POS} from "../../geo/Pos"; +import { useRef } from "react" +import { NULL_POS } from "../../geo/Pos" export interface BallActionProps { onDrop: (el: DOMRect) => void } -export default function BallAction({onDrop}: BallActionProps) { +export default function BallAction({ onDrop }: BallActionProps) { const ref = useRef(null) return ( onDrop(ref.current!.getBoundingClientRect())} position={NULL_POS}>
- +
) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index e46fb7b..46598f2 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -29,7 +29,7 @@ import Draggable from "react-draggable" export interface BendableArrowProps { area: RefObject - startPos: Pos + startPos: Pos | string segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void forceStraight: boolean @@ -55,7 +55,7 @@ const ArrowStyleDefaults: ArrowStyle = { } export interface Segment { - next: Pos + next: Pos | string controlPoint?: Pos } @@ -162,8 +162,8 @@ export default function BendableArrow({ return segments.flatMap(({ next, controlPoint }, i) => { const prev = i == 0 ? startPos : segments[i - 1].next - const prevRelative = posWithinBase(prev, parentBase) - const nextRelative = posWithinBase(next, parentBase) + const prevRelative = getPosWithinBase(prev, parentBase) + const nextRelative = getPosWithinBase(next, parentBase) const cpPos = controlPoint || @@ -204,7 +204,7 @@ export default function BendableArrow({ { const currentSegment = segments[i] @@ -252,19 +252,19 @@ export default function BendableArrow({ const lastSegment = internalSegments[internalSegments.length - 1] - const startRelative = posWithinBase(startPos, parentBase) - const endRelative = posWithinBase(lastSegment.end, parentBase) + const startRelative = getPosWithinBase(startPos, parentBase) + const endRelative = getPosWithinBase(lastSegment.end, parentBase) const startNext = segment.controlPoint && !forceStraight ? posWithinBase(segment.controlPoint, parentBase) - : posWithinBase(segment.end, parentBase) + : getPosWithinBase(segment.end, parentBase) const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint ? posWithinBase(lastSegment.controlPoint, parentBase) - : posWithinBase(lastSegment.start, parentBase) + : getPosWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -313,11 +313,11 @@ export default function BendableArrow({ const svgPosRelativeToBase = { x: left, y: top } const nextRelative = relativeTo( - posWithinBase(end, parentBase), + getPosWithinBase(end, parentBase), svgPosRelativeToBase, ) const startRelative = relativeTo( - posWithinBase(start, parentBase), + getPosWithinBase(start, parentBase), svgPosRelativeToBase, ) const controlPointRelative = @@ -382,6 +382,22 @@ export default function BendableArrow({ // Will update the arrow when the props change useEffect(update, [update]) + useEffect(() => { + const observer = new MutationObserver(update) + const config = { attributes: true } + if (typeof startPos == "string") { + observer.observe(document.getElementById(startPos)!, config) + } + + for (const segment of segments) { + if (typeof segment.next == "string") { + observer.observe(document.getElementById(segment.next)!, config) + } + } + + return () => observer.disconnect() + }, [startPos, segments]) + // Adds a selection handler // Also force an update when the window is resized useEffect(() => { @@ -418,10 +434,16 @@ export default function BendableArrow({ for (let i = 0; i < segments.length; i++) { const segment = segments[i] const beforeSegment = i != 0 ? segments[i - 1] : undefined - const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos + const beforeSegmentPos = getRatioWithinBase( + i > 1 ? segments[i - 2].next : startPos, + parentBase, + ) - const currentPos = beforeSegment ? beforeSegment.next : startPos - const nextPos = segment.next + const currentPos = getRatioWithinBase( + beforeSegment ? beforeSegment.next : startPos, + parentBase, + ) + const nextPos = getRatioWithinBase(segment.next, parentBase) const segmentCp = segment.controlPoint ? segment.controlPoint : middle(currentPos, nextPos) @@ -529,6 +551,24 @@ export default function BendableArrow({ ) } +function getPosWithinBase(target: Pos | string, area: DOMRect): Pos { + if (typeof target != "string") { + return posWithinBase(target, area) + } + + const targetPos = document.getElementById(target)!.getBoundingClientRect() + return relativeTo(middlePos(targetPos), area) +} + +function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos { + if (typeof target != "string") { + return target + } + + const targetPos = document.getElementById(target)!.getBoundingClientRect() + return ratioWithinBase(middlePos(targetPos), area) +} + interface ControlPointProps { className: string posRatio: Pos @@ -546,9 +586,9 @@ enum PointSegmentSearchResult { } interface FullSegment { - start: Pos + start: Pos | string controlPoint: Pos | null - end: Pos + end: Pos | string } /** diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index f684e1b..5815f7a 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,36 +1,41 @@ -import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState,} from "react" -import {Action} from "../../model/tactic/Action" - -import {CourtAction} from "../../views/editor/CourtAction" -import {TacticComponent} from "../../model/tactic/Tactic" +import { + ReactElement, + ReactNode, + RefObject, + useEffect, + useLayoutEffect, + useState, +} from "react" +import { Action } from "../../model/tactic/Action" + +import { CourtAction } from "../../views/editor/CourtAction" +import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" export interface BasketCourtProps { components: TacticComponent[] - actions: Action[] - previewAction: Action | null + previewAction: ActionPreview | null renderComponent: (comp: TacticComponent) => ReactNode - renderAction: (action: Action, idx: number) => ReactNode + renderActions: (comp: TacticComponent) => ReactNode[] courtImage: ReactElement courtRef: RefObject } +export interface ActionPreview extends Action { + origin: ComponentId +} + export function BasketCourt({ components, - actions, previewAction, renderComponent, - renderAction, + renderActions, courtImage, courtRef, }: BasketCourtProps) { - const [internActions, setInternActions] = useState([]) - - useLayoutEffect(() => setInternActions(actions), [actions]) - return (
renderAction(action, idx))} + {components.flatMap(renderActions)} {previewAction && ( {}} onActionChanges={() => {}} diff --git a/front/components/editor/CourtBall.tsx b/front/components/editor/CourtBall.tsx index 53ae408..b167126 100644 --- a/front/components/editor/CourtBall.tsx +++ b/front/components/editor/CourtBall.tsx @@ -6,17 +6,11 @@ import { Ball } from "../../model/tactic/CourtObjects" export interface CourtBallProps { onPosValidated: (rect: DOMRect) => void - onMoves: () => void onRemove: () => void ball: Ball } -export function CourtBall({ - onPosValidated, - ball, - onRemove, - onMoves, -}: CourtBallProps) { +export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { const pieceRef = useRef(null) const x = ball.rightRatio @@ -27,7 +21,6 @@ export function CourtBall({ onStop={() => onPosValidated(pieceRef.current!.getBoundingClientRect()) } - onDrag={onMoves} position={NULL_POS} nodeRef={pieceRef}>
void onPositionValidated: (newPos: Pos) => void onRemove: () => void courtRef: RefObject @@ -23,7 +22,6 @@ export default function CourtPlayer({ playerInfo, className, - onMoves, onPositionValidated, onRemove, courtRef, @@ -38,7 +36,6 @@ export default function CourtPlayer({ { diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index 1179dc7..54b6198 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,20 +1,24 @@ -import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" -import {middlePos, ratioWithinBase} from "../geo/Pos" -import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" -import {overlaps} from "../geo/Box" -import {Action, ActionKind} from "../model/tactic/Action" -import {removeBall, updateComponent} from "./TacticContentDomains" -import {getOrigin} from "./PlayerDomains" - -export function refreshAllActions( - actions: Action[], - components: TacticComponent[], -) { - return actions.map((action) => ({ - ...action, - type: getActionKindFrom(action.fromId, action.toId, components), - })) -} +import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" +import { middlePos, ratioWithinBase } from "../geo/Pos" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { Action, ActionKind } from "../model/tactic/Action" +import { removeBall, updateComponent } from "./TacticContentDomains" +import { getOrigin } from "./PlayerDomains" + +// export function refreshAllActions( +// actions: Action[], +// components: TacticComponent[], +// ) { +// return actions.map((action) => ({ +// ...action, +// type: getActionKindFrom(action.fromId, action.toId, components), +// })) +// } export function getActionKindFrom( originId: ComponentId, @@ -22,7 +26,7 @@ export function getActionKindFrom( components: TacticComponent[], ): ActionKind { const origin = components.find((p) => p.id == originId)! - const target = components.find(p => p.id == targetId) + const target = components.find((p) => p.id == targetId) let ballState = BallState.NONE @@ -30,12 +34,17 @@ export function getActionKindFrom( ballState = origin.ballState } - let hasTarget = target ? (target.type != 'phantom' || target.originPlayerId != origin.id) : false + let hasTarget = target + ? target.type != "phantom" || target.originPlayerId != origin.id + : false return getActionKind(hasTarget, ballState) } -export function getActionKind(hasTarget: boolean, ballState: BallState): ActionKind { +export function getActionKind( + hasTarget: boolean, + ballState: BallState, +): ActionKind { switch (ballState) { case BallState.HOLDS: return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE @@ -51,20 +60,14 @@ export function placeArrow( courtBounds: DOMRect, arrowHead: DOMRect, content: TacticContent, -): { createdAction: Action, newContent: TacticContent } { - const originRef = document.getElementById(origin.id)! - const start = ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ) - +): { createdAction: Action; newContent: TacticContent } { /** * Creates a new phantom component. * Be aware that this function will reassign the `content` parameter. * @param receivesBall */ function createPhantom(receivesBall: boolean): ComponentId { - const {x, y} = ratioWithinBase(arrowHead, courtBounds) + const { x, y } = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -99,16 +102,17 @@ export function placeArrow( const ballState = receivesBall ? BallState.HOLDS : origin.ballState == BallState.HOLDS - ? BallState.HOLDS - : BallState.NONE + ? BallState.HOLDS + : BallState.NONE const phantom: PlayerPhantom = { + actions: [], type: "phantom", id: phantomId, rightRatio: x, bottomRatio: y, originPlayerId: originPlayer.id, - ballState + ballState, } content = { ...content, @@ -127,12 +131,6 @@ export function placeArrow( .getBoundingClientRect() if (overlaps(componentBounds, arrowHead)) { - const targetPos = document - .getElementById(component.id)! - .getBoundingClientRect() - - const end = ratioWithinBase(middlePos(targetPos), courtBounds) - let toId = component.id if (component.type == "ball") { @@ -141,19 +139,20 @@ export function placeArrow( } const action: Action = { - fromId: originRef.id, - toId, + target: toId, type: getActionKind(true, origin.ballState), - moveFrom: start, - segments: [{next: end}], + segments: [{ next: component.id }], } return { - newContent: { - ...content, - actions: [...content.actions, action], - }, - createdAction: action + newContent: updateComponent( + { + ...origin, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, } } } @@ -161,54 +160,37 @@ export function placeArrow( const phantomId = createPhantom(origin.ballState == BallState.HOLDS) const action: Action = { - fromId: originRef.id, - toId: phantomId, + target: phantomId, type: getActionKind(false, origin.ballState), - moveFrom: ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ), - segments: [ - {next: ratioWithinBase(middlePos(arrowHead), courtBounds)}, - ], + segments: [{ next: phantomId }], } return { - newContent: { - ...content, - actions: [...content.actions, action], - }, - createdAction: action + newContent: updateComponent( + { + ...content.components.find((c) => c.id == origin.id)!, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, } } -export function repositionActionsRelatedTo( - compId: ComponentId, - courtBounds: DOMRect, - actions: Action[], -): Action[] { - const posRect = document.getElementById(compId)?.getBoundingClientRect() - const newPos = posRect != undefined - ? ratioWithinBase(middlePos(posRect), courtBounds) - : undefined - - return actions.flatMap((action) => { - if (newPos == undefined) { - return [] - } - - if (action.fromId == compId) { - return [{...action, moveFrom: newPos}] - } - - if (action.toId == compId) { - const lastIdx = action.segments.length - 1 - const segments = action.segments.toSpliced(lastIdx, 1, { - ...action.segments[lastIdx], - next: newPos!, - }) - return [{...action, segments}] - } +export function removeAllActionsTargeting( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + let components = [] + for (let i = 0; i < content.components.length; i++) { + const component = content.components[i] + components.push({ + ...component, + actions: component.actions.filter((a) => a.target != componentId), + }) + } - return action - }) + return { + ...content, + components, + } } diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts index 9ef1d45..b7c69df 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,6 +1,7 @@ import { Player, PlayerPhantom } from "../model/tactic/Player" import { TacticComponent, TacticContent } from "../model/tactic/Tactic" import { removeComponent, updateComponent } from "./TacticContentDomains" +import { removeAllActionsTargeting } from "./ActionsDomains" export function getOrigin( pathItem: PlayerPhantom, @@ -34,6 +35,8 @@ export function removePlayer( player: Player | PlayerPhantom, content: TacticContent, ): TacticContent { + content = removeAllActionsTargeting(player.id, content) + if (player.type == "phantom") { const origin = getOrigin(player, content.components) return truncatePlayerPath(origin, player, content) @@ -54,10 +57,10 @@ export function truncatePlayerPath( let truncateStartIdx = -1 - for (let j = 0; j < path.items.length; j++) { - const pathPhantomId = path.items[j] + for (let i = 0; i < path.items.length; i++) { + const pathPhantomId = path.items[i] if (truncateStartIdx != -1 || pathPhantomId == phantom.id) { - if (truncateStartIdx == -1) truncateStartIdx = j + if (truncateStartIdx == -1) truncateStartIdx = i //remove the phantom from the tactic content = removeComponent(pathPhantomId, content) diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index bec65bc..d0a24ba 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -1,18 +1,31 @@ -import {Pos, ratioWithinBase} from "../geo/Pos" -import {BallState, Player, PlayerInfo, PlayerTeam} from "../model/tactic/Player" -import {Ball, BALL_ID, BALL_TYPE, CourtObject} from "../model/tactic/CourtObjects" -import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" -import {overlaps} from "../geo/Box" -import {RackedCourtObject, RackedPlayer} from "./RackedItems" -import {refreshAllActions} from "./ActionsDomains" -import {getOrigin} from "./PlayerDomains"; +import { Pos, ratioWithinBase } from "../geo/Pos" +import { + BallState, + Player, + PlayerInfo, + PlayerTeam, +} from "../model/tactic/Player" +import { + Ball, + BALL_ID, + BALL_TYPE, + CourtObject, +} from "../model/tactic/CourtObjects" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { RackedCourtObject, RackedPlayer } from "./RackedItems" +import { getOrigin } from "./PlayerDomains" export function placePlayerAt( refBounds: DOMRect, courtBounds: DOMRect, element: RackedPlayer, ): Player { - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) return { type: "player", @@ -23,6 +36,7 @@ export function placePlayerAt( bottomRatio: y, ballState: BallState.NONE, path: null, + actions: [], } } @@ -32,7 +46,7 @@ export function placeObjectAt( rackedObject: RackedCourtObject, content: TacticContent, ): TacticContent { - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -52,6 +66,7 @@ export function placeObjectAt( id: BALL_ID, rightRatio: x, bottomRatio: y, + actions: [], } break @@ -75,10 +90,10 @@ export function dropBallOnComponent( let origin let isPhantom: boolean - if (component.type == 'phantom') { + if (component.type == "phantom") { isPhantom = true origin = getOrigin(component, components) - } else if (component.type == 'player') { + } else if (component.type == "player") { isPhantom = false origin = component } else { @@ -91,11 +106,17 @@ export function dropBallOnComponent( }) if (origin.path != null) { const phantoms = origin.path!.items - const headingPhantoms = isPhantom ? phantoms.slice(phantoms.indexOf(component.id)) : phantoms - components = components.map(c => headingPhantoms.indexOf(c.id) != -1 ? { - ...c, - hasBall: true - } : c) + const headingPhantoms = isPhantom + ? phantoms.slice(phantoms.indexOf(component.id)) + : phantoms + components = components.map((c) => + headingPhantoms.indexOf(c.id) != -1 + ? { + ...c, + hasBall: true, + } + : c, + ) } const ballObj = components.findIndex((p) => p.type == BALL_TYPE) @@ -109,7 +130,6 @@ export function dropBallOnComponent( } return { ...content, - actions: refreshAllActions(content.actions, components), components, } } @@ -118,11 +138,11 @@ export function removeBall(content: TacticContent): TacticContent { const ballObj = content.components.findIndex((o) => o.type == "ball") const components = content.components.map((c) => - (c.type == 'player' || c.type == 'phantom') + c.type == "player" || c.type == "phantom" ? { - ...c, - hasBall: false, - } + ...c, + hasBall: false, + } : c, ) @@ -133,7 +153,6 @@ export function removeBall(content: TacticContent): TacticContent { return { ...content, - actions: refreshAllActions(content.actions, components), components, } } @@ -147,7 +166,7 @@ export function placeBallAt( removed: boolean } { if (!overlaps(courtBounds, refBounds)) { - return {newContent: removeBall(content), removed: true} + return { newContent: removeBall(content), removed: true } } const playerCollidedIdx = getComponentCollided( refBounds, @@ -159,11 +178,11 @@ export function placeBallAt( newContent: dropBallOnComponent(playerCollidedIdx, { ...content, components: content.components.map((c) => - c.type == "player" || c.type == 'phantom' + c.type == "player" || c.type == "phantom" ? { - ...c, - hasBall: false, - } + ...c, + hasBall: false, + } : c, ), }), @@ -173,14 +192,14 @@ export function placeBallAt( const ballIdx = content.components.findIndex((o) => o.type == "ball") - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) const components = content.components.map((c) => c.type == "player" || c.type == "phantom" ? { - ...c, - hasBall: false, - } + ...c, + hasBall: false, + } : c, ) @@ -189,6 +208,7 @@ export function placeBallAt( id: BALL_ID, rightRatio: x, bottomRatio: y, + actions: [], } if (ballIdx != -1) { components.splice(ballIdx, 1, ball) @@ -199,7 +219,6 @@ export function placeBallAt( return { newContent: { ...content, - actions: refreshAllActions(content.actions, components), components, }, removed: false, @@ -243,9 +262,6 @@ export function removeComponent( return { ...content, components: content.components.toSpliced(componentIdx, 1), - actions: content.actions.filter( - (a) => a.toId !== componentId && a.fromId !== componentId, - ), } } @@ -295,5 +311,5 @@ export function getRackPlayers( c.type == "player" && c.team == team && c.role == role, ) == -1, ) - .map((key) => ({team, key})) + .map((key) => ({ team, key })) } diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index f22dfaf..be5b155 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -12,8 +12,7 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - fromId: ComponentId - toId: ComponentId | null - moveFrom: Pos + // fromId: ComponentId + target: ComponentId | Pos segments: Segment[] } diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index 7df59ec..41738d3 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -45,7 +45,7 @@ export interface PlayerInfo { export enum BallState { NONE, HOLDS, - SHOOTED + SHOOTED, } export interface Player extends Component<"player">, PlayerInfo { diff --git a/front/model/tactic/Tactic.ts b/front/model/tactic/Tactic.ts index c641ac4..dfe1190 100644 --- a/front/model/tactic/Tactic.ts +++ b/front/model/tactic/Tactic.ts @@ -10,7 +10,7 @@ export interface Tactic { export interface TacticContent { components: TacticComponent[] - actions: Action[] + //actions: Action[] } export type TacticComponent = Player | CourtObject | PlayerPhantom @@ -34,4 +34,6 @@ export interface Component { * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) */ readonly rightRatio: number + + readonly actions: Action[] } diff --git a/front/style/actions/arrow_action.css b/front/style/actions/arrow_action.css index 3aa88d7..77bfa4c 100644 --- a/front/style/actions/arrow_action.css +++ b/front/style/actions/arrow_action.css @@ -5,6 +5,7 @@ .arrow-action-icon { user-select: none; -moz-user-select: none; + -webkit-user-drag: none; max-width: 17px; max-height: 17px; } diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 5163bcc..7a2321b 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,23 +1,34 @@ -import {CSSProperties, Dispatch, SetStateAction, useCallback, useMemo, useRef, useState,} from "react" +import { + CSSProperties, + Dispatch, + SetStateAction, + useCallback, + useMemo, + useRef, + useState, +} from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import {BallPiece} from "../components/editor/BallPiece" +import { BallPiece } from "../components/editor/BallPiece" -import {Rack} from "../components/Rack" -import {PlayerPiece} from "../components/editor/PlayerPiece" +import { Rack } from "../components/Rack" +import { PlayerPiece } from "../components/editor/PlayerPiece" -import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" -import {fetchAPI} from "../Fetcher" +import { Tactic, TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { fetchAPI } from "../Fetcher" -import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" -import {BALL_TYPE} from "../model/tactic/CourtObjects" -import {CourtAction} from "./editor/CourtAction" -import {BasketCourt} from "../components/editor/BasketCourt" -import {overlaps} from "../geo/Box" +import { BALL_TYPE } from "../model/tactic/CourtObjects" +import { CourtAction } from "./editor/CourtAction" +import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" +import { overlaps } from "../geo/Box" import { dropBallOnComponent, getComponentCollided, @@ -26,19 +37,30 @@ import { placeBallAt, placeObjectAt, placePlayerAt, - removeBall, updateComponent, + removeBall, + updateComponent, } from "../editor/TacticContentDomains" -import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" -import {RackedCourtObject} from "../editor/RackedItems" +import { + BallState, + Player, + PlayerInfo, + PlayerPhantom, + PlayerTeam, +} from "../model/tactic/Player" +import { RackedCourtObject } from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import {getActionKind, placeArrow, repositionActionsRelatedTo,} from "../editor/ActionsDomains" +import { getActionKind, placeArrow } from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" -import {middlePos, ratioWithinBase} from "../geo/Pos" -import {Action, ActionKind} from "../model/tactic/Action" +import { middlePos, ratioWithinBase } from "../geo/Pos" +import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" -import {getOrigin, removePlayer, truncatePlayerPath,} from "../editor/PlayerDomains" -import {CourtBall} from "../components/editor/CourtBall" -import {BASE} from "../Constants" +import { + getOrigin, + removePlayer, + truncatePlayerPath, +} from "../editor/PlayerDomains" +import { CourtBall } from "../components/editor/CourtBall" +import { BASE } from "../Constants" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -61,7 +83,7 @@ export interface EditorProps { courtType: "PLAIN" | "HALF" } -export default function Editor({id, name, courtType, content}: EditorProps) { +export default function Editor({ id, name, courtType, content }: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) @@ -87,7 +109,7 @@ export default function Editor({id, name, courtType, content}: EditorProps) { ) return SaveStates.Guest } - return fetchAPI(`tactic/${id}/save`, {content}).then((r) => + return fetchAPI(`tactic/${id}/save`, { content }).then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} @@ -96,7 +118,7 @@ export default function Editor({id, name, courtType, content}: EditorProps) { 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( + return fetchAPI(`tactic/${id}/edit/name`, { name }).then( (r) => r.ok, ) }} @@ -106,11 +128,11 @@ export default function Editor({id, name, courtType, content}: EditorProps) { } function EditorView({ - tactic: {id, name, content: initialContent}, - onContentChange, - onNameChange, - courtType, - }: EditorViewProps) { + tactic: { id, name, content: initialContent }, + onContentChange, + onNameChange, + courtType, +}: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -130,27 +152,24 @@ function EditorView({ ), ) - const [allies, setAllies] = useState( - () => getRackPlayers(PlayerTeam.Allies, content.components), + const [allies, setAllies] = useState(() => + getRackPlayers(PlayerTeam.Allies, content.components), ) - const [opponents, setOpponents] = useState( - () => getRackPlayers(PlayerTeam.Opponents, content.components), + const [opponents, setOpponents] = useState(() => + getRackPlayers(PlayerTeam.Opponents, content.components), ) - const [objects, setObjects] = useState( - () => isBallOnCourt(content) ? [] : [{key: "ball"}], + const [objects, setObjects] = useState(() => + isBallOnCourt(content) ? [] : [{ key: "ball" }], ) - const [previewAction, setPreviewAction] = useState(null) + const [previewAction, setPreviewAction] = useState( + null, + ) const courtRef = useRef(null) - const setActions = (action: SetStateAction) => { - setContent((c) => ({ - ...c, - actions: typeof action == "function" ? action(c.actions) : action, - })) - } + const actionsReRenderHooks = [] const setComponents = (action: SetStateAction) => { setContent((c) => ({ @@ -170,7 +189,7 @@ function EditorView({ setter = setAllies } if (player.ballState == BallState.HOLDS) { - setObjects([{key: "ball"}]) + setObjects([{ key: "ball" }]) } setter((players) => [ ...players, @@ -184,14 +203,14 @@ function EditorView({ const doMoveBall = (newBounds: DOMRect) => { setContent((content) => { - const {newContent, removed} = placeBallAt( + const { newContent, removed } = placeBallAt( newBounds, courtBounds(), content, ) if (removed) { - setObjects((objects) => [...objects, {key: "ball"}]) + setObjects((objects) => [...objects, { key: "ball" }]) } return newContent @@ -209,13 +228,18 @@ function EditorView({ const origin = getOrigin(component, content.components) const path = origin.path! // phantoms can only place other arrows if they are the head of the path - canPlaceArrows = path.items.indexOf(component.id) == path.items.length - 1 + canPlaceArrows = + path.items.indexOf(component.id) == path.items.length - 1 if (canPlaceArrows) { // and if their only action is to shoot the ball // list the actions the phantoms does - const phantomArrows = content.actions.filter(c => c.fromId == component.id) - canPlaceArrows = phantomArrows.length == 0 || phantomArrows.findIndex(c => c.type != ActionKind.SHOOT) == -1 + const phantomActions = component.actions + canPlaceArrows = + phantomActions.length == 0 || + phantomActions.findIndex( + (c) => c.type != ActionKind.SHOOT, + ) == -1 } info = { @@ -230,7 +254,11 @@ function EditorView({ // a player info = component // can place arrows only if the - canPlaceArrows = component.path == null || content.actions.findIndex(p => p.fromId == component.id && p.type != ActionKind.SHOOT) == -1 + canPlaceArrows = + component.path == null || + component.actions.findIndex( + (p) => p.type != ActionKind.SHOOT, + ) == -1 } return ( @@ -238,11 +266,6 @@ function EditorView({ key={component.id} className={isPhantom ? "phantom" : "player"} playerInfo={info} - onMoves={() => - setActions((actions) => - repositionActionsRelatedTo(info.id, courtBounds(), actions), - ) - } onPositionValidated={(newPos) => { setContent((content) => moveComponent( @@ -264,13 +287,16 @@ function EditorView({ if (!isPhantom) insertRackedPlayer(component) }} courtRef={courtRef} - availableActions={(pieceRef) => [ + availableActions={() => [ canPlaceArrows && ( { const arrowHeadPos = middlePos(headPos) - const targetIdx = getComponentCollided(headPos, content.components) + const targetIdx = getComponentCollided( + headPos, + content.components, + ) setPreviewAction((action) => ({ ...action!, @@ -282,20 +308,20 @@ function EditorView({ ), }, ], - type: getActionKind(targetIdx != -1, info.ballState), + type: getActionKind( + targetIdx != -1, + info.ballState, + ), })) }} onHeadPicked={(headPos) => { - (document.activeElement as HTMLElement).blur() + ;(document.activeElement as HTMLElement).blur() setPreviewAction({ + origin: component.id, type: getActionKind(false, info.ballState), - fromId: info.id, - toId: null, - moveFrom: ratioWithinBase( - middlePos( - pieceRef.getBoundingClientRect(), - ), + target: ratioWithinBase( + headPos, courtBounds(), ), segments: [ @@ -310,25 +336,41 @@ function EditorView({ }} onHeadDropped={(headRect) => { setContent((content) => { - let {createdAction, newContent} = placeArrow( - component, - courtBounds(), - headRect, - content, - ) + let { createdAction, newContent } = + placeArrow( + component, + courtBounds(), + headRect, + content, + ) let originNewBallState = component.ballState - if (createdAction.type == ActionKind.SHOOT) { - const targetIdx = newContent.components.findIndex(c => c.id == createdAction.toId) - newContent = dropBallOnComponent(targetIdx, newContent) + if ( + createdAction.type == ActionKind.SHOOT + ) { + const targetIdx = + newContent.components.findIndex( + (c) => + c.id == + createdAction.target, + ) + newContent = dropBallOnComponent( + targetIdx, + newContent, + ) originNewBallState = BallState.SHOOTED } - newContent = updateComponent({ - ...(newContent.components.find(c => c.id == component.id)! as Player | PlayerPhantom), - ballState: originNewBallState - }, newContent) + newContent = updateComponent( + { + ...(newContent.components.find( + (c) => c.id == component.id, + )! as Player | PlayerPhantom), + ballState: originNewBallState, + }, + newContent, + ) return newContent }) setPreviewAction(null) @@ -336,16 +378,54 @@ function EditorView({ /> ), info.ballState != BallState.NONE && ( - + ), ]} /> ) } + const doDeleteAction = ( + action: Action, + idx: number, + component: TacticComponent, + ) => { + setContent((content) => { + content = updateComponent( + { + ...component, + actions: component.actions.toSpliced(idx, 1), + }, + content, + ) + + if (action.target == null) return content + + const target = content.components.find( + (c) => action.target == c.id, + )! + + if (target.type == "phantom") { + let path = null + if (component.type == "player") { + path = component.path + } else if (component.type == "phantom") { + path = getOrigin(component, content.components).path + } + + if ( + path == null || + path.items.find((c) => c == target.id) == null + ) { + return content + } + content = removePlayer(target, content) + } + + return content + }) + } + return (
@@ -353,7 +433,7 @@ function EditorView({ Home
- +
-
+
@@ -387,7 +467,7 @@ function EditorView({ ), ]) } - render={({team, key}) => ( + render={({ team, key }) => ( ( + render={({ team, key }) => ( } + courtImage={} courtRef={courtRef} previewAction={previewAction} renderComponent={(component) => { @@ -465,20 +544,14 @@ function EditorView({ key="ball" ball={component} onPosValidated={doMoveBall} - onMoves={() => - setActions((actions) => - repositionActionsRelatedTo( - component.id, - courtBounds(), - actions, - ), - ) - } onRemove={() => { setContent((content) => removeBall(content), ) - setObjects(objects => [...objects, {key: "ball"}]) + setObjects((objects) => [ + ...objects, + { key: "ball" }, + ]) }} /> ) @@ -487,60 +560,35 @@ function EditorView({ "unknown tactic component " + component, ) }} - renderAction={(action, i) => ( - { - setContent((content) => { - content = { - ...content, - actions: - content.actions.toSpliced( - i, - 1, - ), - } - - if (action.toId == null) - return content - - const target = - content.components.find( - (c) => action.toId == c.id, - )! - - if (target.type == "phantom") { - const origin = getOrigin( - target, - content.components, - ) - if (origin.id != action.fromId) { - return content - } - content = truncatePlayerPath( - origin, - target, + renderActions={(component) => + component.actions.map((action, i) => ( + { + doDeleteAction(action, i, component) + }} + onActionChanges={(a) => + setContent((content) => + updateComponent( + { + ...component, + actions: + component.actions.toSpliced( + i, + 1, + a, + ), + }, content, - ) - } - - return content - }) - }} - onActionChanges={(a) => - setContent((content) => ({ - ...content, - actions: content.actions.toSpliced( - i, - 1, - a, - ), - })) - } - /> - )} + ), + ) + } + /> + )) + } />
@@ -552,25 +600,27 @@ function EditorView({ function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( - (c) => (c.type == "player" && c.ballState == BallState.HOLDS) || c.type == BALL_TYPE, + (c) => + (c.type == "player" && c.ballState == BallState.HOLDS) || + c.type == BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({courtType}: { courtType: string }) { +function Court({ courtType }: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 22a4147..e4f5fa9 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -2,8 +2,11 @@ import { Action, ActionKind } from "../../model/tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" import { RefObject } from "react" import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" +import { ComponentId } from "../../model/tactic/Tactic" +import { middlePos, Pos, ratioWithinBase } from "../../geo/Pos" export interface CourtActionProps { + origin: ComponentId action: Action onActionChanges: (a: Action) => void onActionDeleted: () => void @@ -11,6 +14,7 @@ export interface CourtActionProps { } export function CourtAction({ + origin, action, onActionChanges, onActionDeleted, @@ -39,14 +43,14 @@ export function CourtAction({ { onActionChanges({ ...action, segments: edges }) }} wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants - endRadius={action.toId ? 26 : 17} + endRadius={action.target ? 26 : 17} startRadius={10} onDeleteRequested={onActionDeleted} style={{ diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 0763818..2971de9 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -26,7 +26,7 @@ CREATE TABLE Tactic name varchar NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, owner integer NOT NULL, - content varchar DEFAULT '{"components": [], "actions": []}' NOT NULL, + content varchar DEFAULT '{"components": []}' NOT NULL, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL, FOREIGN KEY (owner) REFERENCES Account ); diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index b6ebd6e..ec21324 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -42,7 +42,7 @@ class EditorController { return ViewHttpResponse::react("views/Editor.tsx", [ "id" => -1, //-1 id means that the editor will not support saves "name" => TacticModel::TACTIC_DEFAULT_NAME, - "content" => '{"components": [], "actions": []}', + "content" => '{"components": []}', "courtType" => $courtType->name(), ]); } From e97821a4faac3031f88c7c3d42288dd47645d965 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 14 Jan 2024 18:23:22 +0100 Subject: [PATCH 23/44] stabilize phantoms, spread changes between players and phantoms if an action changes --- .eslintrc.js | 2 + front/components/TitleInput.tsx | 6 +- front/components/actions/ArrowAction.tsx | 8 +- front/components/arrows/BendableArrow.tsx | 142 +++-- front/components/editor/BasketCourt.tsx | 3 + front/components/editor/CourtPlayer.tsx | 34 +- front/editor/ActionsDomains.ts | 328 ++++++++-- front/editor/PlayerDomains.ts | 74 ++- front/editor/TacticContentDomains.ts | 154 ++--- front/model/tactic/Action.ts | 5 +- front/model/tactic/Player.ts | 6 +- front/views/Editor.tsx | 717 +++++++++++----------- front/views/editor/CourtAction.tsx | 19 +- 13 files changed, 846 insertions(+), 652 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index aa4a8bc..16f7f84 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,8 @@ module.exports = { 'plugin:react/jsx-runtime', 'plugin:react-hooks/recommended' ], + rules: { + }, settings: { react: { version: 'detect' diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index 477e3d0..25f4697 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -4,13 +4,13 @@ import "../style/title_input.css" export interface TitleInputOptions { style: CSSProperties default_value: string - on_validated: (a: string) => void + onValidated: (a: string) => void } export default function TitleInput({ style, default_value, - on_validated, + onValidated, }: TitleInputOptions) { const [value, setValue] = useState(default_value) const ref = useRef(null) @@ -23,7 +23,7 @@ export default function TitleInput({ type="text" value={value} onChange={(event) => setValue(event.target.value)} - onBlur={(_) => on_validated(value)} + onBlur={(_) => onValidated(value)} onKeyUp={(event) => { if (event.key == "Enter") ref.current?.blur() }} diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 00a661c..86e1a49 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -44,18 +44,18 @@ export default function ArrowAction({ ) } -export function ScreenHead() { +export function ScreenHead({color}: {color: string}) { return (
) } -export function MoveToHead() { +export function MoveToHead({color}: {color: string}) { return ( - + ) } diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 46598f2..5a3ac2d 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,5 +1,6 @@ import { CSSProperties, + MouseEvent as ReactMouseEvent, ReactElement, RefObject, useCallback, @@ -7,21 +8,21 @@ import { useLayoutEffect, useRef, useState, - MouseEvent as ReactMouseEvent, } from "react" import { add, angle, - middle, distance, + middle, middlePos, minus, mul, + norm, + NULL_POS, Pos, posWithinBase, ratioWithinBase, relativeTo, - norm, } from "../../geo/Pos" import "../../style/bendable_arrows.css" @@ -46,12 +47,14 @@ export interface BendableArrowProps { export interface ArrowStyle { width?: number dashArray?: string + color: string, head?: () => ReactElement tail?: () => ReactElement } const ArrowStyleDefaults: ArrowStyle = { width: 3, + color: "black" } export interface Segment { @@ -96,20 +99,20 @@ function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos { * @constructor */ export default function BendableArrow({ - area, - startPos, + area, + startPos, - segments, - onSegmentsChanges, + segments, + onSegmentsChanges, - forceStraight, - wavy, + forceStraight, + wavy, - style, - startRadius = 0, - endRadius = 0, - onDeleteRequested, -}: BendableArrowProps) { + style, + startRadius = 0, + endRadius = 0, + onDeleteRequested, + }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) @@ -134,7 +137,7 @@ export default function BendableArrow({ } }) }, - [segments, startPos], + [startPos], ) // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint), @@ -147,7 +150,7 @@ export default function BendableArrow({ // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) - }, [computeInternalSegments]) + }, [computeInternalSegments, segments]) const [isSelected, setIsSelected] = useState(false) @@ -159,7 +162,7 @@ export default function BendableArrow({ * @param parentBase */ function computePoints(parentBase: DOMRect) { - return segments.flatMap(({ next, controlPoint }, i) => { + return segments.flatMap(({next, controlPoint}, i) => { const prev = i == 0 ? startPos : segments[i - 1].next const prevRelative = getPosWithinBase(prev, parentBase) @@ -245,6 +248,8 @@ export default function BendableArrow({ * Updates the states based on given parameters, which causes the arrow to re-render. */ const update = useCallback(() => { + + const parentBase = area.current!.getBoundingClientRect() const segment = internalSegments[0] ?? null @@ -263,8 +268,8 @@ export default function BendableArrow({ const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint - ? posWithinBase(lastSegment.controlPoint, parentBase) - : getPosWithinBase(lastSegment.start, parentBase) + ? posWithinBase(lastSegment.controlPoint, parentBase) + : getPosWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -302,15 +307,15 @@ export default function BendableArrow({ const segmentsRelatives = ( forceStraight ? [ - { - start: startPos, - controlPoint: undefined, - end: lastSegment.end, - }, - ] + { + start: startPos, + controlPoint: undefined, + end: lastSegment.end, + }, + ] : internalSegments - ).map(({ start, controlPoint, end }, idx) => { - const svgPosRelativeToBase = { x: left, y: top } + ).map(({start, controlPoint, end}) => { + const svgPosRelativeToBase = {x: left, y: top} const nextRelative = relativeTo( getPosWithinBase(end, parentBase), @@ -323,9 +328,9 @@ export default function BendableArrow({ const controlPointRelative = controlPoint && !forceStraight ? relativeTo( - posWithinBase(controlPoint, parentBase), - svgPosRelativeToBase, - ) + posWithinBase(controlPoint, parentBase), + svgPosRelativeToBase, + ) : middle(startRelative, nextRelative) return { @@ -336,7 +341,7 @@ export default function BendableArrow({ }) const computedSegments = segmentsRelatives - .map(({ start, cp, end: e }, idx) => { + .map(({start, cp, end: e}, idx) => { let end = e if (idx == segmentsRelatives.length - 1) { //if it is the last element @@ -355,14 +360,14 @@ export default function BendableArrow({ ? add(start, previousSegmentCpAndCurrentPosVector) : cp - if (wavy) { - return wavyBezier(start, smoothCp, cp, end, 10, 10) - } - if (forceStraight) { return `L${end.x} ${end.y}` } + if (wavy) { + return wavyBezier(start, smoothCp, cp, end, 10, 10) + } + return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}` }) .join(" ") @@ -370,21 +375,14 @@ export default function BendableArrow({ const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) - }, [ - startPos, - internalSegments, - forceStraight, - startRadius, - endRadius, - style, - ]) + }, [area, internalSegments, startPos, forceStraight, startRadius, endRadius, wavy]) // Will update the arrow when the props change useEffect(update, [update]) useEffect(() => { const observer = new MutationObserver(update) - const config = { attributes: true } + const config = {attributes: true} if (typeof startPos == "string") { observer.observe(document.getElementById(startPos)!, config) } @@ -396,7 +394,7 @@ export default function BendableArrow({ } return () => observer.disconnect() - }, [startPos, segments]) + }, [startPos, segments, update]) // Adds a selection handler // Also force an update when the window is resized @@ -423,7 +421,7 @@ export default function BendableArrow({ if (forceStraight) return const parentBase = area.current!.getBoundingClientRect() - const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY } + const clickAbsolutePos: Pos = {x: e.pageX, y: e.pageY} const clickPosBaseRatio = ratioWithinBase( clickAbsolutePos, parentBase, @@ -450,13 +448,13 @@ export default function BendableArrow({ const smoothCp = beforeSegment ? add( - currentPos, - minus( - currentPos, - beforeSegment.controlPoint ?? - middle(beforeSegmentPos, currentPos), - ), - ) + currentPos, + minus( + currentPos, + beforeSegment.controlPoint ?? + middle(beforeSegmentPos, currentPos), + ), + ) : segmentCp const result = searchOnSegment( @@ -504,7 +502,7 @@ export default function BendableArrow({ return (
+ style={{position: "absolute", top: 0, left: 0}}> {style?.head?.call(style)}
{style?.tail?.call(style)}
@@ -556,8 +554,8 @@ function getPosWithinBase(target: Pos | string, area: DOMRect): Pos { return posWithinBase(target, area) } - const targetPos = document.getElementById(target)!.getBoundingClientRect() - return relativeTo(middlePos(targetPos), area) + const targetPos = document.getElementById(target)?.getBoundingClientRect() + return targetPos ? relativeTo(middlePos(targetPos), area) : NULL_POS } function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos { @@ -565,8 +563,8 @@ function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos { return target } - const targetPos = document.getElementById(target)!.getBoundingClientRect() - return ratioWithinBase(middlePos(targetPos), area) + const targetPos = document.getElementById(target)?.getBoundingClientRect() + return targetPos ? ratioWithinBase(middlePos(targetPos), area) : NULL_POS } interface ControlPointProps { @@ -613,7 +611,7 @@ function wavyBezier( const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) const velocityLength = norm(velocity) //rotate the velocity by 90 deg - const projection = { x: velocity.y, y: -velocity.x } + const projection = {x: velocity.y, y: -velocity.x} return { x: (projection.x / velocityLength) * amplitude, @@ -635,7 +633,7 @@ function wavyBezier( // 3 : down to middle let phase = 0 - for (let t = step; t <= 1; ) { + for (let t = step; t <= 1;) { const pos = cubicBeziers(start, cp1, cp2, end, t) const amplification = getVerticalAmplification(t) @@ -753,14 +751,14 @@ function searchOnSegment( * @constructor */ function ArrowPoint({ - className, - posRatio, - parentBase, - onMoves, - onPosValidated, - onRemove, - radius = 7, -}: ControlPointProps) { + className, + posRatio, + parentBase, + onMoves, + onPosValidated, + onRemove, + radius = 7, + }: ControlPointProps) { const ref = useRef(null) const pos = posWithinBase(posRatio, parentBase) @@ -776,7 +774,7 @@ function ArrowPoint({ const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(ratioWithinBase(pointPos, parentBase)) }} - position={{ x: pos.x - radius, y: pos.y - radius }}> + position={{x: pos.x - radius, y: pos.y - radius}}>
{}} onActionChanges={() => {}} diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 43058fe..fbea302 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,9 +1,9 @@ -import { ReactNode, RefObject, useRef } from "react" +import React, {ReactNode, RefObject, useCallback, useRef} from "react" import "../../style/player.css" import Draggable from "react-draggable" -import { PlayerPiece } from "./PlayerPiece" -import { BallState, PlayerInfo } from "../../model/tactic/Player" -import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos" +import {PlayerPiece} from "./PlayerPiece" +import {BallState, PlayerInfo} from "../../model/tactic/Player" +import {NULL_POS, Pos, ratioWithinBase} from "../../geo/Pos" export interface CourtPlayerProps { playerInfo: PlayerInfo @@ -19,14 +19,14 @@ export interface CourtPlayerProps { * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ export default function CourtPlayer({ - playerInfo, - className, + playerInfo, + className, - onPositionValidated, - onRemove, - courtRef, - availableActions, -}: CourtPlayerProps) { + onPositionValidated, + onRemove, + courtRef, + availableActions, + }: CourtPlayerProps) { const usesBall = playerInfo.ballState != BallState.NONE const x = playerInfo.rightRatio const y = playerInfo.bottomRatio @@ -38,13 +38,15 @@ export default function CourtPlayer({ nodeRef={pieceRef} //The piece is positioned using top/bottom style attributes instead position={NULL_POS} - onStop={() => { + onStop={useCallback(() => { const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect() const pos = ratioWithinBase(pieceBounds, parentBounds) - onPositionValidated(pos) - }}> + + if (pos.x !== x || pos.y != y) + onPositionValidated(pos) + }, [courtRef, onPositionValidated, x, y])}>
{ + onKeyUp={useCallback((e: React.KeyboardEvent) => { if (e.key == "Delete") onRemove() - }}> + }, [onRemove])}>
{availableActions(pieceRef.current!)}
diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index 54b6198..cbb21c2 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,61 +1,139 @@ -import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" -import { middlePos, ratioWithinBase } from "../geo/Pos" -import { - ComponentId, - TacticComponent, - TacticContent, -} from "../model/tactic/Tactic" -import { overlaps } from "../geo/Box" -import { Action, ActionKind } from "../model/tactic/Action" -import { removeBall, updateComponent } from "./TacticContentDomains" -import { getOrigin } from "./PlayerDomains" - -// export function refreshAllActions( -// actions: Action[], -// components: TacticComponent[], -// ) { -// return actions.map((action) => ({ -// ...action, -// type: getActionKindFrom(action.fromId, action.toId, components), -// })) -// } - -export function getActionKindFrom( - originId: ComponentId, - targetId: ComponentId | null, - components: TacticComponent[], +import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" +import {ratioWithinBase} from "../geo/Pos" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {Action, ActionKind, moves} from "../model/tactic/Action" +import {removeBall, updateComponent} from "./TacticContentDomains" +import {areInSamePath, changePlayerBallState, getOrigin, isNextInPath, removePlayer} from "./PlayerDomains" +import {BALL_TYPE} from "../model/tactic/CourtObjects"; + +export function getActionKind( + target: TacticComponent | null, + ballState: BallState, ): ActionKind { - const origin = components.find((p) => p.id == originId)! - const target = components.find((p) => p.id == targetId) + switch (ballState) { + case BallState.HOLDS_ORIGIN: + case BallState.HOLDS_BY_PASS: + return target + ? ActionKind.SHOOT + : ActionKind.DRIBBLE + case BallState.PASSED_ORIGIN: + case BallState.PASSED: + case BallState.NONE: + return target && target.type != BALL_TYPE + ? ActionKind.SCREEN + : ActionKind.MOVE + } +} + +export function getActionKindBetween(origin: Player | PlayerPhantom, target: TacticComponent | null, state: BallState): ActionKind { + //remove the target if the target is a phantom that is within the origin's path + if (target != null && target.type == 'phantom' && areInSamePath(origin, target)) { + target = null; + } + + return getActionKind(target, state) +} + +export function isActionValid(origin: TacticComponent, target: TacticComponent | null, components: TacticComponent[]): boolean { + /// action is valid if the origin is neither a phantom nor a player + if (origin.type != "phantom" && origin.type != "player") { + return true + } + + // action is invalid if the origin already moves (unless the origin holds a ball which will lead to a ball pass) + if (origin.actions.find(a => moves(a.type)) && origin.ballState != BallState.HOLDS_BY_PASS) { + return false + } + //Action is valid if the target is null + if (target == null) { + return true + } + + // action is invalid if it targets its own origin + if (origin.id === target.id) { + return false + } + + // action is invalid if the target already moves and is not indirectly bound with origin + if (target.actions.find(a => moves(a.type)) && (hasBoundWith(target, origin, components) || hasBoundWith(origin, target, components))) { + return false + } + + // Action is invalid if there is already an action between origin and target. + if (origin.actions.find(a => a.target === target?.id) || target?.actions.find(a => a.target === origin.id)) { + return false + } - let ballState = BallState.NONE - if (origin.type == "player" || origin.type == "phantom") { - ballState = origin.ballState + // Action is invalid if there is already an anterior action within the target's path + if (target.type == "phantom" || target.type == "player") { + + // cant have an action with current path + if (areInSamePath(origin, target)) + return false; + + + if (alreadyHasAnAnteriorActionWith(origin, target, components)) { + return false + } } - let hasTarget = target - ? target.type != "phantom" || target.originPlayerId != origin.id - : false + return true +} + +function hasBoundWith(origin: TacticComponent, target: TacticComponent, components: TacticComponent[]): boolean { + const toVisit = [origin.id] + const visited: string[] = [] + + let itemId: string | undefined + while ((itemId = toVisit.pop())) { + + if (visited.indexOf(itemId) !== -1) + continue + + visited.push(itemId) + + const item = components.find(c => c.id === itemId)! - return getActionKind(hasTarget, ballState) + const itemBounds = item.actions.flatMap(a => typeof a.target == "string" ? [a.target] : []) + if (itemBounds.indexOf(target.id) !== -1) { + return true + } + + toVisit.push(...itemBounds) + } + + return false } -export function getActionKind( - hasTarget: boolean, - ballState: BallState, -): ActionKind { - switch (ballState) { - case BallState.HOLDS: - return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE - case BallState.SHOOTED: - return ActionKind.MOVE - case BallState.NONE: - return hasTarget ? ActionKind.SCREEN : ActionKind.MOVE +function alreadyHasAnAnteriorActionWith(origin: Player | PlayerPhantom, target: Player | PlayerPhantom, components: TacticComponent[]): boolean { + const targetOrigin = target.type === "phantom" ? getOrigin(target, components) : target + const targetOriginPath = [targetOrigin.id, ...(targetOrigin.path?.items ?? [])] + + const originOrigin = origin.type === "phantom" ? getOrigin(origin, components) : origin + const originOriginPath = [originOrigin.id, ...(originOrigin.path?.items ?? [])] + + const targetIdx = targetOriginPath.indexOf(target.id) + for (let i = targetIdx; i < targetOriginPath.length; i++) { + const phantom = components.find(c => c.id === targetOriginPath[i])! as (Player | PlayerPhantom) + if (phantom.actions.find(a => typeof a.target === "string" && (originOriginPath.indexOf(a.target) !== -1))) { + return true; + } + } + + const originIdx = originOriginPath.indexOf(origin.id) + for (let i = 0; i <= originIdx; i++) { + const phantom = components.find(c => c.id === originOriginPath[i])! as (Player | PlayerPhantom) + if (phantom.actions.find(a => typeof a.target === "string" && targetOriginPath.indexOf(a.target) > targetIdx)) { + return true; + } } + + return false; } -export function placeArrow( +export function createAction( origin: Player | PlayerPhantom, courtBounds: DOMRect, arrowHead: DOMRect, @@ -64,10 +142,9 @@ export function placeArrow( /** * Creates a new phantom component. * Be aware that this function will reassign the `content` parameter. - * @param receivesBall */ - function createPhantom(receivesBall: boolean): ComponentId { - const { x, y } = ratioWithinBase(arrowHead, courtBounds) + function createPhantom(originState: BallState): ComponentId { + const {x, y} = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -99,20 +176,27 @@ export function placeArrow( content, ) - const ballState = receivesBall - ? BallState.HOLDS - : origin.ballState == BallState.HOLDS - ? BallState.HOLDS - : BallState.NONE + let phantomState: BallState + switch (originState) { + case BallState.HOLDS_ORIGIN: + phantomState = BallState.HOLDS_BY_PASS + break + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + phantomState = BallState.NONE + break + default: + phantomState = originState + } const phantom: PlayerPhantom = { - actions: [], type: "phantom", id: phantomId, rightRatio: x, bottomRatio: y, originPlayerId: originPlayer.id, - ballState, + ballState: phantomState, + actions: [], } content = { ...content, @@ -140,14 +224,14 @@ export function placeArrow( const action: Action = { target: toId, - type: getActionKind(true, origin.ballState), - segments: [{ next: component.id }], + type: getActionKind(component, origin.ballState), + segments: [{next: toId}], } return { newContent: updateComponent( { - ...origin, + ...content.components.find((c) => c.id == origin.id)!, actions: [...origin.actions, action], }, content, @@ -157,12 +241,12 @@ export function placeArrow( } } - const phantomId = createPhantom(origin.ballState == BallState.HOLDS) + const phantomId = createPhantom(origin.ballState) const action: Action = { target: phantomId, - type: getActionKind(false, origin.ballState), - segments: [{ next: phantomId }], + type: getActionKind(null, origin.ballState), + segments: [{next: phantomId}], } return { newContent: updateComponent( @@ -180,7 +264,7 @@ export function removeAllActionsTargeting( componentId: ComponentId, content: TacticContent, ): TacticContent { - let components = [] + const components = [] for (let i = 0; i < content.components.length; i++) { const component = content.components[i] components.push({ @@ -194,3 +278,119 @@ export function removeAllActionsTargeting( components, } } + + +export function removeAction(origin: TacticComponent, action: Action, actionIdx: number, content: TacticContent): TacticContent { + origin = { + ...origin, + actions: origin.actions.toSpliced(actionIdx, 1), + } + content = updateComponent( + origin, + content, + ) + + if (action.target == null) return content + + const target = content.components.find( + (c) => action.target == c.id, + )! + + // if the removed action is a shoot, set the origin as holding the ball + if (action.type == ActionKind.SHOOT && (origin.type === "player" || origin.type === "phantom")) { + if (origin.ballState === BallState.PASSED) + content = changePlayerBallState(origin, BallState.HOLDS_BY_PASS, content) + else if (origin.ballState === BallState.PASSED_ORIGIN) + content = changePlayerBallState(origin, BallState.HOLDS_ORIGIN, content) + + if (target.type === "player" || target.type === "phantom") + content = changePlayerBallState(target, BallState.NONE, content) + } + + if (target.type === "phantom") { + let path = null + if (origin.type === "player") { + path = origin.path + } else if (origin.type === "phantom") { + path = getOrigin(origin, content.components).path + } + + if ( + path != null && + path.items.find((c) => c == target.id) + ) { + content = removePlayer(target, content) + } + } + + + + return content +} + +/** + * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with + * the given newState. + * @param origin + * @param newState + * @param content + */ +export function spreadNewStateFromOriginStateChange(origin: Player | PlayerPhantom, newState: BallState, content: TacticContent): TacticContent { + if (origin.ballState === newState) { + return content + } + + origin = { + ...origin, + ballState: newState + } + + content = updateComponent(origin, content) + + for (let i = 0; i < origin.actions.length; i++) { + const action = origin.actions[i] + if (typeof action.target !== "string") { + continue; + } + + const actionTarget = content.components.find(c => action.target === c.id)! as Player | PlayerPhantom; + + let targetState: BallState = actionTarget.ballState + let deleteAction = false + + if (isNextInPath(origin, actionTarget, content.components)) { + /// If the target is the next phantom from the origin, its state is propagated. + targetState = (newState === BallState.PASSED || newState === BallState.PASSED_ORIGIN) ? BallState.NONE : newState + } else if (newState === BallState.NONE && action.type === ActionKind.SHOOT) { + /// if the new state removes the ball from the player, remove all actions that were meant to shoot the ball + deleteAction = true + targetState = BallState.NONE // then remove the ball for the target as well + } else if ((newState === BallState.HOLDS_BY_PASS || newState === BallState.HOLDS_ORIGIN) && action.type === ActionKind.SCREEN) { + targetState = BallState.HOLDS_BY_PASS + } + + if (deleteAction) { + content = removeAction(origin, action, i, content) + origin = content.components.find(c => c.id === origin.id)! as Player | PlayerPhantom + i--; // step back + } else { + // do not change the action type if it is a shoot action + const type = action.type == ActionKind.SHOOT + ? ActionKind.SHOOT + : getActionKindBetween(origin, actionTarget, newState) + + origin = { + ...origin, + actions: origin.actions.toSpliced(i, 1, { + ...action, + type + }) + } + content = updateComponent(origin, content) + } + + content = spreadNewStateFromOriginStateChange(actionTarget, targetState, content) + } + + return content +} \ No newline at end of file diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts index b7c69df..08f70b8 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,7 +1,8 @@ -import { Player, PlayerPhantom } from "../model/tactic/Player" -import { TacticComponent, TacticContent } from "../model/tactic/Tactic" -import { removeComponent, updateComponent } from "./TacticContentDomains" -import { removeAllActionsTargeting } from "./ActionsDomains" +import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" +import {TacticComponent, TacticContent} from "../model/tactic/Tactic" +import {removeComponent, updateComponent} from "./TacticContentDomains" +import {removeAllActionsTargeting, spreadNewStateFromOriginStateChange} from "./ActionsDomains" +import {ActionKind} from "../model/tactic/Action"; export function getOrigin( pathItem: PlayerPhantom, @@ -11,6 +12,36 @@ export function getOrigin( return components.find((c) => c.id == pathItem.originPlayerId)! as Player } +export function areInSamePath( + a: Player | PlayerPhantom, + b: Player | PlayerPhantom, +) { + if (a.type === "phantom" && b.type === "phantom") { + return a.originPlayerId === b.originPlayerId + } + if (a.type === "phantom") { + return b.id === a.originPlayerId + } + if (b.type === "phantom") { + return a.id === b.originPlayerId + } + return false +} + +/** + * @param origin + * @param other + * @param components + * @returns true if the `other` player is the phantom next-to the origin's path. + */ +export function isNextInPath(origin: Player | PlayerPhantom, other: Player | PlayerPhantom, components: TacticComponent[]): boolean { + if (origin.type === "player") { + return origin.path?.items[0] === other.id + } + const originPath = getOrigin(origin, components).path! + return originPath.items!.indexOf(origin.id) === originPath.items!.indexOf(other.id) - 1 +} + export function removePlayerPath( player: Player, content: TacticContent, @@ -21,6 +52,7 @@ export function removePlayerPath( for (const pathElement of player.path.items) { content = removeComponent(pathElement, content) + content = removeAllActionsTargeting(pathElement, content) } return updateComponent( { @@ -43,7 +75,17 @@ export function removePlayer( } content = removePlayerPath(player, content) - return removeComponent(player.id, content) + content = removeComponent(player.id, content) + + for (const action of player.actions) { + if (action.type !== ActionKind.SHOOT) { + continue + } + const actionTarget = content.components.find(c => c.id === action.target)! as (Player | PlayerPhantom) + return spreadNewStateFromOriginStateChange(actionTarget, BallState.NONE, content) + } + + return content } export function truncatePlayerPath( @@ -55,16 +97,14 @@ export function truncatePlayerPath( const path = player.path! - let truncateStartIdx = -1 + const truncateStartIdx = path.items.indexOf(phantom.id) - for (let i = 0; i < path.items.length; i++) { + for (let i = truncateStartIdx; i < path.items.length; i++) { const pathPhantomId = path.items[i] - if (truncateStartIdx != -1 || pathPhantomId == phantom.id) { - if (truncateStartIdx == -1) truncateStartIdx = i - //remove the phantom from the tactic - content = removeComponent(pathPhantomId, content) - } + //remove the phantom from the tactic + content = removeComponent(pathPhantomId, content) + content = removeAllActionsTargeting(pathPhantomId, content) } return updateComponent( @@ -74,10 +114,14 @@ export function truncatePlayerPath( truncateStartIdx == 0 ? null : { - ...path, - items: path.items.toSpliced(truncateStartIdx), - }, + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, }, content, ) } + +export function changePlayerBallState(player: Player | PlayerPhantom, newState: BallState, content: TacticContent): TacticContent { + return spreadNewStateFromOriginStateChange(player, newState, content) +} \ No newline at end of file diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index d0a24ba..d252a10 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -1,31 +1,17 @@ -import { Pos, ratioWithinBase } from "../geo/Pos" -import { - BallState, - Player, - PlayerInfo, - PlayerTeam, -} from "../model/tactic/Player" -import { - Ball, - BALL_ID, - BALL_TYPE, - CourtObject, -} from "../model/tactic/CourtObjects" -import { - ComponentId, - TacticComponent, - TacticContent, -} from "../model/tactic/Tactic" -import { overlaps } from "../geo/Box" -import { RackedCourtObject, RackedPlayer } from "./RackedItems" -import { getOrigin } from "./PlayerDomains" +import {Pos, ratioWithinBase} from "../geo/Pos" +import {BallState, Player, PlayerInfo, PlayerTeam,} from "../model/tactic/Player" +import {Ball, BALL_ID, BALL_TYPE, CourtObject,} from "../model/tactic/CourtObjects" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {RackedCourtObject, RackedPlayer} from "./RackedItems" +import {changePlayerBallState} from "./PlayerDomains" export function placePlayerAt( refBounds: DOMRect, courtBounds: DOMRect, element: RackedPlayer, ): Player { - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const {x, y} = ratioWithinBase(refBounds, courtBounds) return { type: "player", @@ -46,7 +32,7 @@ export function placeObjectAt( rackedObject: RackedCourtObject, content: TacticContent, ): TacticContent { - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const {x, y} = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -58,7 +44,7 @@ export function placeObjectAt( BALL_ID, ) if (playerCollidedIdx != -1) { - return dropBallOnComponent(playerCollidedIdx, content) + return dropBallOnComponent(playerCollidedIdx, content, true) } courtObject = { @@ -83,77 +69,31 @@ export function placeObjectAt( export function dropBallOnComponent( targetedComponentIdx: number, content: TacticContent, + setAsOrigin: boolean ): TacticContent { - let components = content.components - let component = components[targetedComponentIdx] + const component = content.components[targetedComponentIdx] - let origin - let isPhantom: boolean + if ((component.type == 'player' || component.type == 'phantom')) { + const newState = setAsOrigin + ? (component.ballState === BallState.PASSED || component.ballState === BallState.PASSED_ORIGIN) ? BallState.PASSED_ORIGIN : BallState.HOLDS_ORIGIN + : BallState.HOLDS_BY_PASS - if (component.type == "phantom") { - isPhantom = true - origin = getOrigin(component, components) - } else if (component.type == "player") { - isPhantom = false - origin = component - } else { - return content + content = changePlayerBallState(component, newState, content) } - components = components.toSpliced(targetedComponentIdx, 1, { - ...component, - ballState: BallState.HOLDS, - }) - if (origin.path != null) { - const phantoms = origin.path!.items - const headingPhantoms = isPhantom - ? phantoms.slice(phantoms.indexOf(component.id)) - : phantoms - components = components.map((c) => - headingPhantoms.indexOf(c.id) != -1 - ? { - ...c, - hasBall: true, - } - : c, - ) - } - - const ballObj = components.findIndex((p) => p.type == BALL_TYPE) - - // Maybe the ball is not present on the court as an object component - // if so, don't bother removing it from the court. - // This can occur if the user drags and drop the ball from a player that already has the ball - // to another component - if (ballObj != -1) { - components.splice(ballObj, 1) - } - return { - ...content, - components, - } + return removeBall(content) } export function removeBall(content: TacticContent): TacticContent { - const ballObj = content.components.findIndex((o) => o.type == "ball") - - const components = content.components.map((c) => - c.type == "player" || c.type == "phantom" - ? { - ...c, - hasBall: false, - } - : c, - ) + const ballObjIdx = content.components.findIndex((o) => o.type == "ball") - // if the ball is already not on the court, do nothing - if (ballObj != -1) { - components.splice(ballObj, 1) + if (ballObjIdx == -1) { + return content } return { ...content, - components, + components: content.components.toSpliced(ballObjIdx, 1), } } @@ -161,47 +101,23 @@ export function placeBallAt( refBounds: DOMRect, courtBounds: DOMRect, content: TacticContent, -): { - newContent: TacticContent - removed: boolean -} { +): TacticContent { if (!overlaps(courtBounds, refBounds)) { - return { newContent: removeBall(content), removed: true } + return removeBall(content) } const playerCollidedIdx = getComponentCollided( refBounds, content.components, BALL_ID, ) + if (playerCollidedIdx != -1) { - return { - newContent: dropBallOnComponent(playerCollidedIdx, { - ...content, - components: content.components.map((c) => - c.type == "player" || c.type == "phantom" - ? { - ...c, - hasBall: false, - } - : c, - ), - }), - removed: false, - } + return dropBallOnComponent(playerCollidedIdx, content, true) } const ballIdx = content.components.findIndex((o) => o.type == "ball") - const { x, y } = ratioWithinBase(refBounds, courtBounds) - - const components = content.components.map((c) => - c.type == "player" || c.type == "phantom" - ? { - ...c, - hasBall: false, - } - : c, - ) + const {x, y} = ratioWithinBase(refBounds, courtBounds) const ball: Ball = { type: BALL_TYPE, @@ -210,18 +126,18 @@ export function placeBallAt( bottomRatio: y, actions: [], } + + let components = content.components + if (ballIdx != -1) { - components.splice(ballIdx, 1, ball) + components = components.toSpliced(ballIdx, 1, ball) } else { - components.push(ball) + components = components.concat(ball) } return { - newContent: { - ...content, - components, - }, - removed: false, + ...content, + components, } } @@ -311,5 +227,5 @@ export function getRackPlayers( c.type == "player" && c.team == team && c.role == role, ) == -1, ) - .map((key) => ({ team, key })) + .map((key) => ({team, key})) } diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index be5b155..e590696 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -12,7 +12,10 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - // fromId: ComponentId target: ComponentId | Pos segments: Segment[] } + +export function moves(kind: ActionKind): boolean { + return kind != ActionKind.SHOOT +} \ No newline at end of file diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index 41738d3..ad95b2c 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -44,8 +44,10 @@ export interface PlayerInfo { export enum BallState { NONE, - HOLDS, - SHOOTED, + HOLDS_ORIGIN, + HOLDS_BY_PASS, + PASSED, + PASSED_ORIGIN, } export interface Player extends Component<"player">, PlayerInfo { diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 7a2321b..90f278e 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,8 +1,10 @@ import { CSSProperties, Dispatch, + RefObject, SetStateAction, useCallback, + useEffect, useMemo, useRef, useState, @@ -12,23 +14,20 @@ import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import { BallPiece } from "../components/editor/BallPiece" +import {BallPiece} from "../components/editor/BallPiece" -import { Rack } from "../components/Rack" -import { PlayerPiece } from "../components/editor/PlayerPiece" +import {Rack} from "../components/Rack" +import {PlayerPiece} from "../components/editor/PlayerPiece" -import { Tactic, TacticComponent, TacticContent } from "../model/tactic/Tactic" -import { fetchAPI } from "../Fetcher" +import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" +import {fetchAPI} from "../Fetcher" -import SavingState, { - SaveState, - SaveStates, -} from "../components/editor/SavingState" +import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" -import { BALL_TYPE } from "../model/tactic/CourtObjects" -import { CourtAction } from "./editor/CourtAction" -import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" -import { overlaps } from "../geo/Box" +import {BALL_TYPE} from "../model/tactic/CourtObjects" +import {CourtAction} from "./editor/CourtAction" +import {ActionPreview, BasketCourt} from "../components/editor/BasketCourt" +import {overlaps} from "../geo/Box" import { dropBallOnComponent, getComponentCollided, @@ -40,27 +39,17 @@ import { removeBall, updateComponent, } from "../editor/TacticContentDomains" -import { - BallState, - Player, - PlayerInfo, - PlayerPhantom, - PlayerTeam, -} from "../model/tactic/Player" -import { RackedCourtObject } from "../editor/RackedItems" +import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" +import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import { getActionKind, placeArrow } from "../editor/ActionsDomains" +import {createAction, getActionKind, isActionValid, removeAction} from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../geo/Pos" -import { Action, ActionKind } from "../model/tactic/Action" +import {middlePos, Pos, ratioWithinBase} from "../geo/Pos" +import {Action, ActionKind} from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" -import { - getOrigin, - removePlayer, - truncatePlayerPath, -} from "../editor/PlayerDomains" -import { CourtBall } from "../components/editor/CourtBall" -import { BASE } from "../Constants" +import {changePlayerBallState, getOrigin, removePlayer,} from "../editor/PlayerDomains" +import {CourtBall} from "../components/editor/CourtBall" +import {BASE} from "../Constants" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -83,7 +72,7 @@ export interface EditorProps { courtType: "PLAIN" | "HALF" } -export default function Editor({ id, name, courtType, content }: EditorProps) { +export default function Editor({id, name, courtType, content}: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) @@ -109,7 +98,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { ) return SaveStates.Guest } - return fetchAPI(`tactic/${id}/save`, { content }).then((r) => + return fetchAPI(`tactic/${id}/save`, {content}).then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} @@ -118,7 +107,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { 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( + return fetchAPI(`tactic/${id}/edit/name`, {name}).then( (r) => r.ok, ) }} @@ -128,11 +117,12 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { } function EditorView({ - tactic: { id, name, content: initialContent }, - onContentChange, - onNameChange, - courtType, -}: EditorViewProps) { + tactic: {id, name, content: initialContent}, + onContentChange, + onNameChange, + courtType, + }: EditorViewProps) { + const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -160,7 +150,7 @@ function EditorView({ ) const [objects, setObjects] = useState(() => - isBallOnCourt(content) ? [] : [{ key: "ball" }], + isBallOnCourt(content) ? [] : [{key: "ball"}], ) const [previewAction, setPreviewAction] = useState( @@ -169,8 +159,6 @@ function EditorView({ const courtRef = useRef(null) - const actionsReRenderHooks = [] - const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, @@ -179,6 +167,12 @@ function EditorView({ })) } + const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + + useEffect(() => { + setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) + }, [setObjects, content]); + const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { @@ -188,8 +182,8 @@ function EditorView({ case PlayerTeam.Allies: setter = setAllies } - if (player.ballState == BallState.HOLDS) { - setObjects([{ key: "ball" }]) + if (player.ballState == BallState.HOLDS_BY_PASS) { + setObjects([{key: "ball"}]) } setter((players) => [ ...players, @@ -201,47 +195,97 @@ function EditorView({ ]) } - const doMoveBall = (newBounds: DOMRect) => { + const doRemovePlayer = useCallback((component: Player | PlayerPhantom) => { + setContent((c) => removePlayer(component, c)) + if (component.type == "player") insertRackedPlayer(component) + }, [setContent]) + + const doMoveBall = useCallback((newBounds: DOMRect, from?: Player | PlayerPhantom) => { setContent((content) => { - const { newContent, removed } = placeBallAt( + if (from) { + content = changePlayerBallState(from, BallState.NONE, content) + } + + content = placeBallAt( newBounds, courtBounds(), content, ) - if (removed) { - setObjects((objects) => [...objects, { key: "ball" }]) - } - - return newContent + return content }) - } + }, [courtBounds, setContent]) + + const validatePlayerPosition = useCallback((player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => { + setContent((content) => + moveComponent( + newPos, + player, + info, + courtBounds(), + content, - const courtBounds = () => courtRef.current!.getBoundingClientRect() + (content) => { + if (player.type == "player") insertRackedPlayer(player) + return removePlayer(player, content) + }, + ), + ) + }, [courtBounds, setContent]) - const renderPlayer = (component: Player | PlayerPhantom) => { - let info: PlayerInfo + const renderAvailablePlayerActions = useCallback((info: PlayerInfo, player: Player | PlayerPhantom) => { let canPlaceArrows: boolean - const isPhantom = component.type == "phantom" - if (isPhantom) { - const origin = getOrigin(component, content.components) + if (player.type == "player") { + canPlaceArrows = + player.path == null || + player.actions.findIndex( + (p) => p.type != ActionKind.SHOOT, + ) == -1 + } else { + const origin = getOrigin(player, content.components) const path = origin.path! // phantoms can only place other arrows if they are the head of the path canPlaceArrows = - path.items.indexOf(component.id) == path.items.length - 1 + path.items.indexOf(player.id) == path.items.length - 1 if (canPlaceArrows) { // and if their only action is to shoot the ball - - // list the actions the phantoms does - const phantomActions = component.actions + const phantomActions = player.actions canPlaceArrows = phantomActions.length == 0 || phantomActions.findIndex( (c) => c.type != ActionKind.SHOOT, ) == -1 } + } + + return [ + canPlaceArrows && ( + + ), + (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( + { + doMoveBall(ballBounds, player) + }}/> + ), + ] + }, [content, doMoveBall, previewAction?.isInvalid, setContent]) + + const renderPlayer = useCallback((component: Player | PlayerPhantom) => { + let info: PlayerInfo + const isPhantom = component.type == "phantom" + if (isPhantom) { + const origin = getOrigin(component, content.components) info = { id: component.id, team: origin.team, @@ -251,14 +295,7 @@ function EditorView({ ballState: component.ballState, } } else { - // a player info = component - // can place arrows only if the - canPlaceArrows = - component.path == null || - component.actions.findIndex( - (p) => p.type != ActionKind.SHOOT, - ) == -1 } return ( @@ -266,165 +303,87 @@ function EditorView({ key={component.id} className={isPhantom ? "phantom" : "player"} playerInfo={info} - onPositionValidated={(newPos) => { - setContent((content) => - moveComponent( - newPos, - component, - info, - courtBounds(), - content, - - (content) => { - if (!isPhantom) insertRackedPlayer(component) - return removePlayer(component, content) - }, - ), - ) - }} - onRemove={() => { - setContent((c) => removePlayer(component, c)) - if (!isPhantom) insertRackedPlayer(component) - }} + onPositionValidated={(newPos) => validatePlayerPosition(component, info, newPos)} + onRemove={() => doRemovePlayer(component)} courtRef={courtRef} - availableActions={() => [ - canPlaceArrows && ( - { - const arrowHeadPos = middlePos(headPos) - const targetIdx = getComponentCollided( - headPos, - content.components, - ) - - setPreviewAction((action) => ({ - ...action!, - segments: [ - { - next: ratioWithinBase( - arrowHeadPos, - courtBounds(), - ), - }, - ], - type: getActionKind( - targetIdx != -1, - info.ballState, - ), - })) - }} - onHeadPicked={(headPos) => { - ;(document.activeElement as HTMLElement).blur() - - setPreviewAction({ - origin: component.id, - type: getActionKind(false, info.ballState), - target: ratioWithinBase( - headPos, - courtBounds(), - ), - segments: [ - { - next: ratioWithinBase( - middlePos(headPos), - courtBounds(), - ), - }, - ], - }) - }} - onHeadDropped={(headRect) => { - setContent((content) => { - let { createdAction, newContent } = - placeArrow( - component, - courtBounds(), - headRect, - content, - ) - - let originNewBallState = component.ballState - - if ( - createdAction.type == ActionKind.SHOOT - ) { - const targetIdx = - newContent.components.findIndex( - (c) => - c.id == - createdAction.target, - ) - newContent = dropBallOnComponent( - targetIdx, - newContent, - ) - originNewBallState = BallState.SHOOTED - } - - newContent = updateComponent( - { - ...(newContent.components.find( - (c) => c.id == component.id, - )! as Player | PlayerPhantom), - ballState: originNewBallState, - }, - newContent, - ) - return newContent - }) - setPreviewAction(null) - }} - /> - ), - info.ballState != BallState.NONE && ( - - ), - ]} + availableActions={() => renderAvailablePlayerActions(info, component)} /> ) - } + }, [content.components, doRemovePlayer, renderAvailablePlayerActions, validatePlayerPosition]) - const doDeleteAction = ( + const doDeleteAction = useCallback(( action: Action, idx: number, - component: TacticComponent, + origin: TacticComponent, ) => { - setContent((content) => { - content = updateComponent( + setContent((content) => removeAction(origin, action, idx, content)) + }, [setContent]) + + const doUpdateAction = useCallback((component: TacticComponent, action: Action, actionIndex: number) => { + setContent((content) => + updateComponent( { ...component, - actions: component.actions.toSpliced(idx, 1), + actions: + component.actions.toSpliced( + actionIndex, + 1, + action, + ), }, content, + ), + ) + }, [setContent]) + + const renderComponent = useCallback((component: TacticComponent) => { + if ( + component.type == "player" || + component.type == "phantom" + ) { + return renderPlayer(component) + } + if (component.type == BALL_TYPE) { + return ( + { + setContent((content) => + removeBall(content), + ) + setObjects((objects) => [ + ...objects, + {key: "ball"}, + ]) + }} + /> ) - - if (action.target == null) return content - - const target = content.components.find( - (c) => action.target == c.id, - )! - - if (target.type == "phantom") { - let path = null - if (component.type == "player") { - path = component.path - } else if (component.type == "phantom") { - path = getOrigin(component, content.components).path - } - - if ( - path == null || - path.items.find((c) => c == target.id) == null - ) { - return content - } - content = removePlayer(target, content) - } - - return content - }) - } + } + throw new Error( + "unknown tactic component " + component, + ) + }, [renderPlayer, doMoveBall, setContent]) + + const renderActions = useCallback((component: TacticComponent) => + component.actions.map((action, i) => { + return ( + { + doDeleteAction(action, i, component) + }} + onActionChanges={(action) => + doUpdateAction(component, action, i) + } + /> + ) + }), [doDeleteAction, doUpdateAction]) return (
@@ -433,162 +392,58 @@ function EditorView({ Home
- +
{ + onValidated={useCallback((new_name) => { onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) - }} + }, [onNameChange])} />
-
+
- - overlaps(courtBounds(), div.getBoundingClientRect()) - } - onElementDetached={(r, e) => - setComponents((components) => [ - ...components, - placePlayerAt( - r.getBoundingClientRect(), - courtBounds(), - e, - ), - ]) - } - render={({ team, key }) => ( - - )} - /> + - overlaps(courtBounds(), div.getBoundingClientRect()) - } - onElementDetached={(r, e) => - setContent((content) => - placeObjectAt( - r.getBoundingClientRect(), - courtBounds(), - e, - content, - ), - ) - } + canDetach={useCallback((div) => + overlaps(courtBounds(), div.getBoundingClientRect()) + , [courtBounds])} + onElementDetached={useCallback((r, e: RackedCourtObject) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ) + , [courtBounds, setContent])} render={renderCourtObject} /> - - overlaps(courtBounds(), div.getBoundingClientRect()) - } - onElementDetached={(r, e) => - setComponents((components) => [ - ...components, - placePlayerAt( - r.getBoundingClientRect(), - courtBounds(), - e, - ), - ]) - } - render={({ team, key }) => ( - - )} - /> +
} + courtImage={} courtRef={courtRef} previewAction={previewAction} - renderComponent={(component) => { - if ( - component.type == "player" || - component.type == "phantom" - ) { - return renderPlayer(component) - } - if (component.type == BALL_TYPE) { - return ( - { - setContent((content) => - removeBall(content), - ) - setObjects((objects) => [ - ...objects, - { key: "ball" }, - ]) - }} - /> - ) - } - throw new Error( - "unknown tactic component " + component, - ) - }} - renderActions={(component) => - component.actions.map((action, i) => ( - { - doDeleteAction(action, i, component) - }} - onActionChanges={(a) => - setContent((content) => - updateComponent( - { - ...component, - actions: - component.actions.toSpliced( - i, - 1, - a, - ), - }, - content, - ), - ) - } - /> - )) - } + renderComponent={renderComponent} + renderActions={renderActions} />
@@ -597,11 +452,175 @@ function EditorView({ ) } +interface PlayerRackProps { + id: string + objects: RackedPlayer[] + setObjects: (state: RackedPlayer[]) => void + setComponents: (f: (components: TacticComponent[]) => TacticComponent[]) => void + courtRef: RefObject +} + +function PlayerRack({id, objects, setObjects, courtRef, setComponents}: PlayerRackProps) { + + const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + + return ( + + overlaps(courtBounds(), div.getBoundingClientRect()) + , [courtBounds])} + onElementDetached={useCallback((r, e: RackedPlayer) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]) + , [courtBounds, setComponents])} + render={useCallback(({team, key}: { team: PlayerTeam, key: string }) => ( + + ), [])} + /> + ) +} + +interface CourtPlayerArrowActionProps { + playerInfo: PlayerInfo + player: Player | PlayerPhantom + isInvalid: boolean + + content: TacticContent + setContent: (state: SetStateAction) => void + setPreviewAction: (state: SetStateAction) => void + courtRef: RefObject +} + +function CourtPlayerArrowAction({ + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef + }: CourtPlayerArrowActionProps) { + + const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + + return ( + { + const arrowHeadPos = middlePos(headPos) + const targetIdx = getComponentCollided( + headPos, + content.components, + ) + const target = content.components[targetIdx] + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase( + arrowHeadPos, + courtBounds(), + ), + }, + ], + type: getActionKind( + target, + playerInfo.ballState, + ), + isInvalid: !overlaps(headPos, courtBounds()) || !isActionValid(player, target, content.components) + })) + }} + onHeadPicked={(headPos) => { + (document.activeElement as HTMLElement).blur() + + setPreviewAction({ + origin: playerInfo.id, + type: getActionKind(null, playerInfo.ballState), + target: ratioWithinBase( + headPos, + courtBounds(), + ), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + courtBounds(), + ), + }, + ], + isInvalid: false + }) + }} + onHeadDropped={(headRect) => { + if (isInvalid) { + setPreviewAction(null) + return + } + + setContent((content) => { + let {createdAction, newContent} = + createAction( + player, + courtBounds(), + headRect, + content, + ) + + if ( + createdAction.type == ActionKind.SHOOT + ) { + const targetIdx = + newContent.components.findIndex( + (c) => + c.id == + createdAction.target, + ) + newContent = dropBallOnComponent( + targetIdx, + newContent, + false + ) + newContent = updateComponent( + { + ...(newContent.components.find( + (c) => c.id == player.id, + )! as Player | PlayerPhantom), + ballState: BallState.PASSED, + }, + newContent, + ) + } + + + return newContent + }) + setPreviewAction(null) + }} + /> + ) +} + function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( (c) => - (c.type == "player" && c.ballState == BallState.HOLDS) || + (c.type == "player" && (c.ballState === BallState.HOLDS_ORIGIN || c.ballState === BallState.HOLDS_BY_PASS)) || c.type == BALL_TYPE, ) != -1 ) @@ -609,18 +628,18 @@ function isBallOnCourt(content: TacticContent) { function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({ courtType }: { courtType: string }) { +function Court({courtType}: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index e4f5fa9..986854d 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,9 +1,8 @@ -import { Action, ActionKind } from "../../model/tactic/Action" +import {Action, ActionKind} from "../../model/tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" -import { RefObject } from "react" -import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" -import { ComponentId } from "../../model/tactic/Tactic" -import { middlePos, Pos, ratioWithinBase } from "../../geo/Pos" +import {RefObject} from "react" +import {MoveToHead, ScreenHead} from "../../components/actions/ArrowAction" +import {ComponentId} from "../../model/tactic/Tactic" export interface CourtActionProps { origin: ComponentId @@ -11,6 +10,7 @@ export interface CourtActionProps { onActionChanges: (a: Action) => void onActionDeleted: () => void courtRef: RefObject + isInvalid: boolean } export function CourtAction({ @@ -19,16 +19,20 @@ export function CourtAction({ onActionChanges, onActionDeleted, courtRef, + isInvalid }: CourtActionProps) { + + const color = isInvalid ? "red" : "black" + let head switch (action.type) { case ActionKind.DRIBBLE: case ActionKind.MOVE: case ActionKind.SHOOT: - head = () => + head = () => break case ActionKind.SCREEN: - head = () => + head = () => break } @@ -56,6 +60,7 @@ export function CourtAction({ style={{ head, dashArray, + color }} /> ) From 15c75ee269571fd01abc83b5beb4407a8c54e8d0 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 25 Jan 2024 17:49:10 +0100 Subject: [PATCH 24/44] fixes and format --- front/components/actions/ArrowAction.tsx | 8 +- front/components/arrows/BendableArrow.tsx | 114 ++-- front/components/editor/BasketCourt.tsx | 1 - front/components/editor/CourtPlayer.tsx | 34 +- front/editor/ActionsDomains.ts | 280 ++++++---- front/editor/PlayerDomains.ts | 48 +- front/editor/TacticContentDomains.ts | 45 +- front/model/tactic/Action.ts | 2 +- front/views/Editor.tsx | 649 ++++++++++++---------- front/views/editor/CourtAction.tsx | 15 +- 10 files changed, 702 insertions(+), 494 deletions(-) diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 86e1a49..8fbae5f 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -44,15 +44,13 @@ export default function ArrowAction({ ) } -export function ScreenHead({color}: {color: string}) { +export function ScreenHead({ color }: { color: string }) { return ( -
+
) } -export function MoveToHead({color}: {color: string}) { +export function MoveToHead({ color }: { color: string }) { return ( diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 5a3ac2d..7a4760b 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -47,14 +47,14 @@ export interface BendableArrowProps { export interface ArrowStyle { width?: number dashArray?: string - color: string, + color: string head?: () => ReactElement tail?: () => ReactElement } const ArrowStyleDefaults: ArrowStyle = { width: 3, - color: "black" + color: "black", } export interface Segment { @@ -99,20 +99,20 @@ function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos { * @constructor */ export default function BendableArrow({ - area, - startPos, + area, + startPos, - segments, - onSegmentsChanges, + segments, + onSegmentsChanges, - forceStraight, - wavy, + forceStraight, + wavy, - style, - startRadius = 0, - endRadius = 0, - onDeleteRequested, - }: BendableArrowProps) { + style, + startRadius = 0, + endRadius = 0, + onDeleteRequested, +}: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) @@ -162,7 +162,7 @@ export default function BendableArrow({ * @param parentBase */ function computePoints(parentBase: DOMRect) { - return segments.flatMap(({next, controlPoint}, i) => { + return segments.flatMap(({ next, controlPoint }, i) => { const prev = i == 0 ? startPos : segments[i - 1].next const prevRelative = getPosWithinBase(prev, parentBase) @@ -248,8 +248,6 @@ export default function BendableArrow({ * Updates the states based on given parameters, which causes the arrow to re-render. */ const update = useCallback(() => { - - const parentBase = area.current!.getBoundingClientRect() const segment = internalSegments[0] ?? null @@ -268,8 +266,8 @@ export default function BendableArrow({ const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint - ? posWithinBase(lastSegment.controlPoint, parentBase) - : getPosWithinBase(lastSegment.start, parentBase) + ? posWithinBase(lastSegment.controlPoint, parentBase) + : getPosWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -307,15 +305,15 @@ export default function BendableArrow({ const segmentsRelatives = ( forceStraight ? [ - { - start: startPos, - controlPoint: undefined, - end: lastSegment.end, - }, - ] + { + start: startPos, + controlPoint: undefined, + end: lastSegment.end, + }, + ] : internalSegments - ).map(({start, controlPoint, end}) => { - const svgPosRelativeToBase = {x: left, y: top} + ).map(({ start, controlPoint, end }) => { + const svgPosRelativeToBase = { x: left, y: top } const nextRelative = relativeTo( getPosWithinBase(end, parentBase), @@ -328,9 +326,9 @@ export default function BendableArrow({ const controlPointRelative = controlPoint && !forceStraight ? relativeTo( - posWithinBase(controlPoint, parentBase), - svgPosRelativeToBase, - ) + posWithinBase(controlPoint, parentBase), + svgPosRelativeToBase, + ) : middle(startRelative, nextRelative) return { @@ -341,7 +339,7 @@ export default function BendableArrow({ }) const computedSegments = segmentsRelatives - .map(({start, cp, end: e}, idx) => { + .map(({ start, cp, end: e }, idx) => { let end = e if (idx == segmentsRelatives.length - 1) { //if it is the last element @@ -375,14 +373,22 @@ export default function BendableArrow({ const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) - }, [area, internalSegments, startPos, forceStraight, startRadius, endRadius, wavy]) + }, [ + area, + internalSegments, + startPos, + forceStraight, + startRadius, + endRadius, + wavy, + ]) // Will update the arrow when the props change useEffect(update, [update]) useEffect(() => { const observer = new MutationObserver(update) - const config = {attributes: true} + const config = { attributes: true } if (typeof startPos == "string") { observer.observe(document.getElementById(startPos)!, config) } @@ -421,7 +427,7 @@ export default function BendableArrow({ if (forceStraight) return const parentBase = area.current!.getBoundingClientRect() - const clickAbsolutePos: Pos = {x: e.pageX, y: e.pageY} + const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY } const clickPosBaseRatio = ratioWithinBase( clickAbsolutePos, parentBase, @@ -448,13 +454,13 @@ export default function BendableArrow({ const smoothCp = beforeSegment ? add( - currentPos, - minus( - currentPos, - beforeSegment.controlPoint ?? - middle(beforeSegmentPos, currentPos), - ), - ) + currentPos, + minus( + currentPos, + beforeSegment.controlPoint ?? + middle(beforeSegmentPos, currentPos), + ), + ) : segmentCp const result = searchOnSegment( @@ -502,7 +508,7 @@ export default function BendableArrow({ return (
+ style={{ position: "absolute", top: 0, left: 0 }}> {style?.head?.call(style)}
{style?.tail?.call(style)}
@@ -611,7 +617,7 @@ function wavyBezier( const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) const velocityLength = norm(velocity) //rotate the velocity by 90 deg - const projection = {x: velocity.y, y: -velocity.x} + const projection = { x: velocity.y, y: -velocity.x } return { x: (projection.x / velocityLength) * amplitude, @@ -633,7 +639,7 @@ function wavyBezier( // 3 : down to middle let phase = 0 - for (let t = step; t <= 1;) { + for (let t = step; t <= 1; ) { const pos = cubicBeziers(start, cp1, cp2, end, t) const amplification = getVerticalAmplification(t) @@ -751,14 +757,14 @@ function searchOnSegment( * @constructor */ function ArrowPoint({ - className, - posRatio, - parentBase, - onMoves, - onPosValidated, - onRemove, - radius = 7, - }: ControlPointProps) { + className, + posRatio, + parentBase, + onMoves, + onPosValidated, + onRemove, + radius = 7, +}: ControlPointProps) { const ref = useRef(null) const pos = posWithinBase(posRatio, parentBase) @@ -774,7 +780,7 @@ function ArrowPoint({ const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(ratioWithinBase(pointPos, parentBase)) }} - position={{x: pos.x - radius, y: pos.y - radius}}> + position={{ x: pos.x - radius, y: pos.y - radius }}>
) => { - if (e.key == "Delete") onRemove() - }, [onRemove])}> + onKeyUp={useCallback( + (e: React.KeyboardEvent) => { + if (e.key == "Delete") onRemove() + }, + [onRemove], + )}>
{availableActions(pieceRef.current!)}
diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index cbb21c2..8bb4200 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,11 +1,21 @@ -import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" -import {ratioWithinBase} from "../geo/Pos" -import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" -import {overlaps} from "../geo/Box" -import {Action, ActionKind, moves} from "../model/tactic/Action" -import {removeBall, updateComponent} from "./TacticContentDomains" -import {areInSamePath, changePlayerBallState, getOrigin, isNextInPath, removePlayer} from "./PlayerDomains" -import {BALL_TYPE} from "../model/tactic/CourtObjects"; +import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" +import { ratioWithinBase } from "../geo/Pos" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { Action, ActionKind, moves } from "../model/tactic/Action" +import { removeBall, updateComponent } from "./TacticContentDomains" +import { + areInSamePath, + changePlayerBallState, + getOrigin, + isNextInPath, + removePlayer, +} from "./PlayerDomains" +import { BALL_TYPE } from "../model/tactic/CourtObjects" export function getActionKind( target: TacticComponent | null, @@ -14,9 +24,7 @@ export function getActionKind( switch (ballState) { case BallState.HOLDS_ORIGIN: case BallState.HOLDS_BY_PASS: - return target - ? ActionKind.SHOOT - : ActionKind.DRIBBLE + return target ? ActionKind.SHOOT : ActionKind.DRIBBLE case BallState.PASSED_ORIGIN: case BallState.PASSED: case BallState.NONE: @@ -26,23 +34,38 @@ export function getActionKind( } } -export function getActionKindBetween(origin: Player | PlayerPhantom, target: TacticComponent | null, state: BallState): ActionKind { +export function getActionKindBetween( + origin: Player | PlayerPhantom, + target: TacticComponent | null, + state: BallState, +): ActionKind { //remove the target if the target is a phantom that is within the origin's path - if (target != null && target.type == 'phantom' && areInSamePath(origin, target)) { - target = null; + if ( + target != null && + target.type == "phantom" && + areInSamePath(origin, target) + ) { + target = null } return getActionKind(target, state) } -export function isActionValid(origin: TacticComponent, target: TacticComponent | null, components: TacticComponent[]): boolean { +export function isActionValid( + origin: TacticComponent, + target: TacticComponent | null, + components: TacticComponent[], +): boolean { /// action is valid if the origin is neither a phantom nor a player if (origin.type != "phantom" && origin.type != "player") { return true } // action is invalid if the origin already moves (unless the origin holds a ball which will lead to a ball pass) - if (origin.actions.find(a => moves(a.type)) && origin.ballState != BallState.HOLDS_BY_PASS) { + if ( + origin.actions.find((a) => moves(a.type)) && + origin.ballState != BallState.HOLDS_BY_PASS + ) { return false } //Action is valid if the target is null @@ -56,23 +79,26 @@ export function isActionValid(origin: TacticComponent, target: TacticComponent | } // action is invalid if the target already moves and is not indirectly bound with origin - if (target.actions.find(a => moves(a.type)) && (hasBoundWith(target, origin, components) || hasBoundWith(origin, target, components))) { + if ( + target.actions.find((a) => moves(a.type)) && + (hasBoundWith(target, origin, components) || + hasBoundWith(origin, target, components)) + ) { return false } // Action is invalid if there is already an action between origin and target. - if (origin.actions.find(a => a.target === target?.id) || target?.actions.find(a => a.target === origin.id)) { + if ( + origin.actions.find((a) => a.target === target?.id) || + target?.actions.find((a) => a.target === origin.id) + ) { return false } - // Action is invalid if there is already an anterior action within the target's path if (target.type == "phantom" || target.type == "player") { - // cant have an action with current path - if (areInSamePath(origin, target)) - return false; - + if (areInSamePath(origin, target)) return false if (alreadyHasAnAnteriorActionWith(origin, target, components)) { return false @@ -82,21 +108,25 @@ export function isActionValid(origin: TacticComponent, target: TacticComponent | return true } -function hasBoundWith(origin: TacticComponent, target: TacticComponent, components: TacticComponent[]): boolean { +function hasBoundWith( + origin: TacticComponent, + target: TacticComponent, + components: TacticComponent[], +): boolean { const toVisit = [origin.id] const visited: string[] = [] let itemId: string | undefined while ((itemId = toVisit.pop())) { - - if (visited.indexOf(itemId) !== -1) - continue + if (visited.indexOf(itemId) !== -1) continue visited.push(itemId) - const item = components.find(c => c.id === itemId)! + const item = components.find((c) => c.id === itemId)! - const itemBounds = item.actions.flatMap(a => typeof a.target == "string" ? [a.target] : []) + const itemBounds = item.actions.flatMap((a) => + typeof a.target == "string" ? [a.target] : [], + ) if (itemBounds.indexOf(target.id) !== -1) { return true } @@ -107,30 +137,58 @@ function hasBoundWith(origin: TacticComponent, target: TacticComponent, componen return false } -function alreadyHasAnAnteriorActionWith(origin: Player | PlayerPhantom, target: Player | PlayerPhantom, components: TacticComponent[]): boolean { - const targetOrigin = target.type === "phantom" ? getOrigin(target, components) : target - const targetOriginPath = [targetOrigin.id, ...(targetOrigin.path?.items ?? [])] - - const originOrigin = origin.type === "phantom" ? getOrigin(origin, components) : origin - const originOriginPath = [originOrigin.id, ...(originOrigin.path?.items ?? [])] +function alreadyHasAnAnteriorActionWith( + origin: Player | PlayerPhantom, + target: Player | PlayerPhantom, + components: TacticComponent[], +): boolean { + const targetOrigin = + target.type === "phantom" ? getOrigin(target, components) : target + const targetOriginPath = [ + targetOrigin.id, + ...(targetOrigin.path?.items ?? []), + ] + + const originOrigin = + origin.type === "phantom" ? getOrigin(origin, components) : origin + const originOriginPath = [ + originOrigin.id, + ...(originOrigin.path?.items ?? []), + ] const targetIdx = targetOriginPath.indexOf(target.id) for (let i = targetIdx; i < targetOriginPath.length; i++) { - const phantom = components.find(c => c.id === targetOriginPath[i])! as (Player | PlayerPhantom) - if (phantom.actions.find(a => typeof a.target === "string" && (originOriginPath.indexOf(a.target) !== -1))) { - return true; + const phantom = components.find( + (c) => c.id === targetOriginPath[i], + )! as Player | PlayerPhantom + if ( + phantom.actions.find( + (a) => + typeof a.target === "string" && + originOriginPath.indexOf(a.target) !== -1, + ) + ) { + return true } } const originIdx = originOriginPath.indexOf(origin.id) for (let i = 0; i <= originIdx; i++) { - const phantom = components.find(c => c.id === originOriginPath[i])! as (Player | PlayerPhantom) - if (phantom.actions.find(a => typeof a.target === "string" && targetOriginPath.indexOf(a.target) > targetIdx)) { - return true; + const phantom = components.find( + (c) => c.id === originOriginPath[i], + )! as Player | PlayerPhantom + if ( + phantom.actions.find( + (a) => + typeof a.target === "string" && + targetOriginPath.indexOf(a.target) > targetIdx, + ) + ) { + return true } } - return false; + return false } export function createAction( @@ -143,8 +201,8 @@ export function createAction( * Creates a new phantom component. * Be aware that this function will reassign the `content` parameter. */ - function createPhantom(originState: BallState): ComponentId { - const {x, y} = ratioWithinBase(arrowHead, courtBounds) + function createPhantom(forceHasBall: boolean): ComponentId { + const { x, y } = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -177,17 +235,19 @@ export function createAction( ) let phantomState: BallState - switch (originState) { - case BallState.HOLDS_ORIGIN: - phantomState = BallState.HOLDS_BY_PASS - break - case BallState.PASSED: - case BallState.PASSED_ORIGIN: - phantomState = BallState.NONE - break - default: - phantomState = originState - } + if (forceHasBall) phantomState = BallState.HOLDS_ORIGIN + else + switch (origin.ballState) { + case BallState.HOLDS_ORIGIN: + phantomState = BallState.HOLDS_BY_PASS + break + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + phantomState = BallState.NONE + break + default: + phantomState = origin.ballState + } const phantom: PlayerPhantom = { type: "phantom", @@ -225,7 +285,7 @@ export function createAction( const action: Action = { target: toId, type: getActionKind(component, origin.ballState), - segments: [{next: toId}], + segments: [{ next: toId }], } return { @@ -241,12 +301,12 @@ export function createAction( } } - const phantomId = createPhantom(origin.ballState) + const phantomId = createPhantom(false) const action: Action = { target: phantomId, type: getActionKind(null, origin.ballState), - segments: [{next: phantomId}], + segments: [{ next: phantomId }], } return { newContent: updateComponent( @@ -279,29 +339,39 @@ export function removeAllActionsTargeting( } } - -export function removeAction(origin: TacticComponent, action: Action, actionIdx: number, content: TacticContent): TacticContent { +export function removeAction( + origin: TacticComponent, + action: Action, + actionIdx: number, + content: TacticContent, +): TacticContent { origin = { ...origin, actions: origin.actions.toSpliced(actionIdx, 1), } - content = updateComponent( - origin, - content, - ) + content = updateComponent(origin, content) if (action.target == null) return content - const target = content.components.find( - (c) => action.target == c.id, - )! + const target = content.components.find((c) => action.target == c.id)! // if the removed action is a shoot, set the origin as holding the ball - if (action.type == ActionKind.SHOOT && (origin.type === "player" || origin.type === "phantom")) { + if ( + action.type == ActionKind.SHOOT && + (origin.type === "player" || origin.type === "phantom") + ) { if (origin.ballState === BallState.PASSED) - content = changePlayerBallState(origin, BallState.HOLDS_BY_PASS, content) + content = changePlayerBallState( + origin, + BallState.HOLDS_BY_PASS, + content, + ) else if (origin.ballState === BallState.PASSED_ORIGIN) - content = changePlayerBallState(origin, BallState.HOLDS_ORIGIN, content) + content = changePlayerBallState( + origin, + BallState.HOLDS_ORIGIN, + content, + ) if (target.type === "player" || target.type === "phantom") content = changePlayerBallState(target, BallState.NONE, content) @@ -315,16 +385,11 @@ export function removeAction(origin: TacticComponent, action: Action, actionIdx: path = getOrigin(origin, content.components).path } - if ( - path != null && - path.items.find((c) => c == target.id) - ) { + if (path != null && path.items.find((c) => c == target.id)) { content = removePlayer(target, content) } } - - return content } @@ -335,14 +400,18 @@ export function removeAction(origin: TacticComponent, action: Action, actionIdx: * @param newState * @param content */ -export function spreadNewStateFromOriginStateChange(origin: Player | PlayerPhantom, newState: BallState, content: TacticContent): TacticContent { +export function spreadNewStateFromOriginStateChange( + origin: Player | PlayerPhantom, + newState: BallState, + content: TacticContent, +): TacticContent { if (origin.ballState === newState) { return content } origin = { ...origin, - ballState: newState + ballState: newState, } content = updateComponent(origin, content) @@ -350,47 +419,72 @@ export function spreadNewStateFromOriginStateChange(origin: Player | PlayerPhant for (let i = 0; i < origin.actions.length; i++) { const action = origin.actions[i] if (typeof action.target !== "string") { - continue; + continue } - const actionTarget = content.components.find(c => action.target === c.id)! as Player | PlayerPhantom; + const actionTarget = content.components.find( + (c) => action.target === c.id, + )! as Player | PlayerPhantom let targetState: BallState = actionTarget.ballState let deleteAction = false if (isNextInPath(origin, actionTarget, content.components)) { - /// If the target is the next phantom from the origin, its state is propagated. - targetState = (newState === BallState.PASSED || newState === BallState.PASSED_ORIGIN) ? BallState.NONE : newState - } else if (newState === BallState.NONE && action.type === ActionKind.SHOOT) { + switch (newState) { + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + targetState = BallState.NONE + break + case BallState.HOLDS_ORIGIN: + targetState = BallState.HOLDS_BY_PASS + break + default: + targetState = newState + } + } else if ( + newState === BallState.NONE && + action.type === ActionKind.SHOOT + ) { /// if the new state removes the ball from the player, remove all actions that were meant to shoot the ball deleteAction = true targetState = BallState.NONE // then remove the ball for the target as well - } else if ((newState === BallState.HOLDS_BY_PASS || newState === BallState.HOLDS_ORIGIN) && action.type === ActionKind.SCREEN) { + } else if ( + (newState === BallState.HOLDS_BY_PASS || + newState === BallState.HOLDS_ORIGIN) && + action.type === ActionKind.SCREEN + ) { targetState = BallState.HOLDS_BY_PASS } if (deleteAction) { content = removeAction(origin, action, i, content) - origin = content.components.find(c => c.id === origin.id)! as Player | PlayerPhantom - i--; // step back + origin = content.components.find((c) => c.id === origin.id)! as + | Player + | PlayerPhantom + i-- // step back } else { // do not change the action type if it is a shoot action - const type = action.type == ActionKind.SHOOT - ? ActionKind.SHOOT - : getActionKindBetween(origin, actionTarget, newState) + const type = + action.type == ActionKind.SHOOT + ? ActionKind.SHOOT + : getActionKindBetween(origin, actionTarget, newState) origin = { ...origin, actions: origin.actions.toSpliced(i, 1, { ...action, - type - }) + type, + }), } content = updateComponent(origin, content) } - content = spreadNewStateFromOriginStateChange(actionTarget, targetState, content) + content = spreadNewStateFromOriginStateChange( + actionTarget, + targetState, + content, + ) } return content -} \ No newline at end of file +} diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts index 08f70b8..39419a2 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,8 +1,11 @@ -import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" -import {TacticComponent, TacticContent} from "../model/tactic/Tactic" -import {removeComponent, updateComponent} from "./TacticContentDomains" -import {removeAllActionsTargeting, spreadNewStateFromOriginStateChange} from "./ActionsDomains" -import {ActionKind} from "../model/tactic/Action"; +import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" +import { TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { removeComponent, updateComponent } from "./TacticContentDomains" +import { + removeAllActionsTargeting, + spreadNewStateFromOriginStateChange, +} from "./ActionsDomains" +import { ActionKind } from "../model/tactic/Action" export function getOrigin( pathItem: PlayerPhantom, @@ -34,12 +37,19 @@ export function areInSamePath( * @param components * @returns true if the `other` player is the phantom next-to the origin's path. */ -export function isNextInPath(origin: Player | PlayerPhantom, other: Player | PlayerPhantom, components: TacticComponent[]): boolean { +export function isNextInPath( + origin: Player | PlayerPhantom, + other: Player | PlayerPhantom, + components: TacticComponent[], +): boolean { if (origin.type === "player") { return origin.path?.items[0] === other.id } const originPath = getOrigin(origin, components).path! - return originPath.items!.indexOf(origin.id) === originPath.items!.indexOf(other.id) - 1 + return ( + originPath.items!.indexOf(origin.id) === + originPath.items!.indexOf(other.id) - 1 + ) } export function removePlayerPath( @@ -81,8 +91,14 @@ export function removePlayer( if (action.type !== ActionKind.SHOOT) { continue } - const actionTarget = content.components.find(c => c.id === action.target)! as (Player | PlayerPhantom) - return spreadNewStateFromOriginStateChange(actionTarget, BallState.NONE, content) + const actionTarget = content.components.find( + (c) => c.id === action.target, + )! as Player | PlayerPhantom + return spreadNewStateFromOriginStateChange( + actionTarget, + BallState.NONE, + content, + ) } return content @@ -114,14 +130,18 @@ export function truncatePlayerPath( truncateStartIdx == 0 ? null : { - ...path, - items: path.items.toSpliced(truncateStartIdx), - }, + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, }, content, ) } -export function changePlayerBallState(player: Player | PlayerPhantom, newState: BallState, content: TacticContent): TacticContent { +export function changePlayerBallState( + player: Player | PlayerPhantom, + newState: BallState, + content: TacticContent, +): TacticContent { return spreadNewStateFromOriginStateChange(player, newState, content) -} \ No newline at end of file +} diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index d252a10..1c6bfda 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -1,17 +1,31 @@ -import {Pos, ratioWithinBase} from "../geo/Pos" -import {BallState, Player, PlayerInfo, PlayerTeam,} from "../model/tactic/Player" -import {Ball, BALL_ID, BALL_TYPE, CourtObject,} from "../model/tactic/CourtObjects" -import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" -import {overlaps} from "../geo/Box" -import {RackedCourtObject, RackedPlayer} from "./RackedItems" -import {changePlayerBallState} from "./PlayerDomains" +import { Pos, ratioWithinBase } from "../geo/Pos" +import { + BallState, + Player, + PlayerInfo, + PlayerTeam, +} from "../model/tactic/Player" +import { + Ball, + BALL_ID, + BALL_TYPE, + CourtObject, +} from "../model/tactic/CourtObjects" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { RackedCourtObject, RackedPlayer } from "./RackedItems" +import { changePlayerBallState } from "./PlayerDomains" export function placePlayerAt( refBounds: DOMRect, courtBounds: DOMRect, element: RackedPlayer, ): Player { - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) return { type: "player", @@ -32,7 +46,7 @@ export function placeObjectAt( rackedObject: RackedCourtObject, content: TacticContent, ): TacticContent { - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -69,13 +83,16 @@ export function placeObjectAt( export function dropBallOnComponent( targetedComponentIdx: number, content: TacticContent, - setAsOrigin: boolean + setAsOrigin: boolean, ): TacticContent { const component = content.components[targetedComponentIdx] - if ((component.type == 'player' || component.type == 'phantom')) { + if (component.type === "player" || component.type === "phantom") { const newState = setAsOrigin - ? (component.ballState === BallState.PASSED || component.ballState === BallState.PASSED_ORIGIN) ? BallState.PASSED_ORIGIN : BallState.HOLDS_ORIGIN + ? component.ballState === BallState.PASSED || + component.ballState === BallState.PASSED_ORIGIN + ? BallState.PASSED_ORIGIN + : BallState.HOLDS_ORIGIN : BallState.HOLDS_BY_PASS content = changePlayerBallState(component, newState, content) @@ -117,7 +134,7 @@ export function placeBallAt( const ballIdx = content.components.findIndex((o) => o.type == "ball") - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) const ball: Ball = { type: BALL_TYPE, @@ -227,5 +244,5 @@ export function getRackPlayers( c.type == "player" && c.team == team && c.role == role, ) == -1, ) - .map((key) => ({team, key})) + .map((key) => ({ team, key })) } diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index e590696..c97cdd4 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -18,4 +18,4 @@ export interface MovementAction { export function moves(kind: ActionKind): boolean { return kind != ActionKind.SHOOT -} \ No newline at end of file +} diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 90f278e..de7c749 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -14,20 +14,23 @@ import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import {BallPiece} from "../components/editor/BallPiece" +import { BallPiece } from "../components/editor/BallPiece" -import {Rack} from "../components/Rack" -import {PlayerPiece} from "../components/editor/PlayerPiece" +import { Rack } from "../components/Rack" +import { PlayerPiece } from "../components/editor/PlayerPiece" -import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" -import {fetchAPI} from "../Fetcher" +import { Tactic, TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { fetchAPI } from "../Fetcher" -import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" -import {BALL_TYPE} from "../model/tactic/CourtObjects" -import {CourtAction} from "./editor/CourtAction" -import {ActionPreview, BasketCourt} from "../components/editor/BasketCourt" -import {overlaps} from "../geo/Box" +import { BALL_TYPE } from "../model/tactic/CourtObjects" +import { CourtAction } from "./editor/CourtAction" +import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" +import { overlaps } from "../geo/Box" import { dropBallOnComponent, getComponentCollided, @@ -39,17 +42,32 @@ import { removeBall, updateComponent, } from "../editor/TacticContentDomains" -import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" -import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems" +import { + BallState, + Player, + PlayerInfo, + PlayerPhantom, + PlayerTeam, +} from "../model/tactic/Player" +import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import {createAction, getActionKind, isActionValid, removeAction} from "../editor/ActionsDomains" +import { + createAction, + getActionKind, + isActionValid, + removeAction, +} from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" -import {middlePos, Pos, ratioWithinBase} from "../geo/Pos" -import {Action, ActionKind} from "../model/tactic/Action" +import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" +import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" -import {changePlayerBallState, getOrigin, removePlayer,} from "../editor/PlayerDomains" -import {CourtBall} from "../components/editor/CourtBall" -import {BASE} from "../Constants" +import { + changePlayerBallState, + getOrigin, + removePlayer, +} from "../editor/PlayerDomains" +import { CourtBall } from "../components/editor/CourtBall" +import { BASE } from "../Constants" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -72,7 +90,7 @@ export interface EditorProps { courtType: "PLAIN" | "HALF" } -export default function Editor({id, name, courtType, content}: EditorProps) { +export default function Editor({ id, name, courtType, content }: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) @@ -98,7 +116,7 @@ export default function Editor({id, name, courtType, content}: EditorProps) { ) return SaveStates.Guest } - return fetchAPI(`tactic/${id}/save`, {content}).then((r) => + return fetchAPI(`tactic/${id}/save`, { content }).then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} @@ -107,7 +125,7 @@ export default function Editor({id, name, courtType, content}: EditorProps) { 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( + return fetchAPI(`tactic/${id}/edit/name`, { name }).then( (r) => r.ok, ) }} @@ -117,12 +135,11 @@ export default function Editor({id, name, courtType, content}: EditorProps) { } function EditorView({ - tactic: {id, name, content: initialContent}, - onContentChange, - onNameChange, - courtType, - }: EditorViewProps) { - + tactic: { id, name, content: initialContent }, + onContentChange, + onNameChange, + courtType, +}: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -150,7 +167,7 @@ function EditorView({ ) const [objects, setObjects] = useState(() => - isBallOnCourt(content) ? [] : [{key: "ball"}], + isBallOnCourt(content) ? [] : [{ key: "ball" }], ) const [previewAction, setPreviewAction] = useState( @@ -167,11 +184,14 @@ function EditorView({ })) } - const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) useEffect(() => { - setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) - }, [setObjects, content]); + setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) + }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { let setter @@ -183,7 +203,7 @@ function EditorView({ setter = setAllies } if (player.ballState == BallState.HOLDS_BY_PASS) { - setObjects([{key: "ball"}]) + setObjects([{ key: "ball" }]) } setter((players) => [ ...players, @@ -195,195 +215,222 @@ function EditorView({ ]) } - const doRemovePlayer = useCallback((component: Player | PlayerPhantom) => { - setContent((c) => removePlayer(component, c)) - if (component.type == "player") insertRackedPlayer(component) - }, [setContent]) + const doRemovePlayer = useCallback( + (component: Player | PlayerPhantom) => { + setContent((c) => removePlayer(component, c)) + if (component.type == "player") insertRackedPlayer(component) + }, + [setContent], + ) + + const doMoveBall = useCallback( + (newBounds: DOMRect, from?: Player | PlayerPhantom) => { + setContent((content) => { + if (from) { + content = changePlayerBallState( + from, + BallState.NONE, + content, + ) + } - const doMoveBall = useCallback((newBounds: DOMRect, from?: Player | PlayerPhantom) => { - setContent((content) => { - if (from) { - content = changePlayerBallState(from, BallState.NONE, content) - } + content = placeBallAt(newBounds, courtBounds(), content) - content = placeBallAt( - newBounds, - courtBounds(), - content, + return content + }) + }, + [courtBounds, setContent], + ) + + const validatePlayerPosition = useCallback( + (player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => { + setContent((content) => + moveComponent( + newPos, + player, + info, + courtBounds(), + content, + + (content) => { + if (player.type == "player") insertRackedPlayer(player) + return removePlayer(player, content) + }, + ), ) + }, + [courtBounds, setContent], + ) - return content - }) - }, [courtBounds, setContent]) - - const validatePlayerPosition = useCallback((player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => { - setContent((content) => - moveComponent( - newPos, - player, - info, - courtBounds(), - content, - - (content) => { - if (player.type == "player") insertRackedPlayer(player) - return removePlayer(player, content) - }, - ), - ) - }, [courtBounds, setContent]) - - const renderAvailablePlayerActions = useCallback((info: PlayerInfo, player: Player | PlayerPhantom) => { - let canPlaceArrows: boolean - - if (player.type == "player") { - canPlaceArrows = - player.path == null || - player.actions.findIndex( - (p) => p.type != ActionKind.SHOOT, - ) == -1 - } else { - const origin = getOrigin(player, content.components) - const path = origin.path! - // phantoms can only place other arrows if they are the head of the path - canPlaceArrows = - path.items.indexOf(player.id) == path.items.length - 1 - if (canPlaceArrows) { - // and if their only action is to shoot the ball - const phantomActions = player.actions + const renderAvailablePlayerActions = useCallback( + (info: PlayerInfo, player: Player | PlayerPhantom) => { + let canPlaceArrows: boolean + + if (player.type == "player") { canPlaceArrows = - phantomActions.length == 0 || - phantomActions.findIndex( - (c) => c.type != ActionKind.SHOOT, + player.path == null || + player.actions.findIndex( + (p) => p.type != ActionKind.SHOOT, ) == -1 + } else { + const origin = getOrigin(player, content.components) + const path = origin.path! + // phantoms can only place other arrows if they are the head of the path + canPlaceArrows = + path.items.indexOf(player.id) == path.items.length - 1 + if (canPlaceArrows) { + // and if their only action is to shoot the ball + const phantomActions = player.actions + canPlaceArrows = + phantomActions.length == 0 || + phantomActions.findIndex( + (c) => c.type != ActionKind.SHOOT, + ) == -1 + } } - } + return [ + canPlaceArrows && ( + + ), + (info.ballState === BallState.HOLDS_ORIGIN || + info.ballState === BallState.PASSED_ORIGIN) && ( + { + doMoveBall(ballBounds, player) + }} + /> + ), + ] + }, + [content, doMoveBall, previewAction?.isInvalid, setContent], + ) + + const renderPlayer = useCallback( + (component: Player | PlayerPhantom) => { + let info: PlayerInfo + const isPhantom = component.type == "phantom" + if (isPhantom) { + const origin = getOrigin(component, content.components) + info = { + id: component.id, + team: origin.team, + role: origin.role, + bottomRatio: component.bottomRatio, + rightRatio: component.rightRatio, + ballState: component.ballState, + } + } else { + info = component + } - return [ - canPlaceArrows && ( - + validatePlayerPosition(component, info, newPos) + } + onRemove={() => doRemovePlayer(component)} courtRef={courtRef} - setContent={setContent} + availableActions={() => + renderAvailablePlayerActions(info, component) + } /> - ), - (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( - { - doMoveBall(ballBounds, player) - }}/> - ), - ] - }, [content, doMoveBall, previewAction?.isInvalid, setContent]) - - const renderPlayer = useCallback((component: Player | PlayerPhantom) => { - let info: PlayerInfo - const isPhantom = component.type == "phantom" - if (isPhantom) { - const origin = getOrigin(component, content.components) - info = { - id: component.id, - team: origin.team, - role: origin.role, - bottomRatio: component.bottomRatio, - rightRatio: component.rightRatio, - ballState: component.ballState, - } - } else { - info = component - } + ) + }, + [ + content.components, + doRemovePlayer, + renderAvailablePlayerActions, + validatePlayerPosition, + ], + ) - return ( - validatePlayerPosition(component, info, newPos)} - onRemove={() => doRemovePlayer(component)} - courtRef={courtRef} - availableActions={() => renderAvailablePlayerActions(info, component)} - /> - ) - }, [content.components, doRemovePlayer, renderAvailablePlayerActions, validatePlayerPosition]) - - const doDeleteAction = useCallback(( - action: Action, - idx: number, - origin: TacticComponent, - ) => { - setContent((content) => removeAction(origin, action, idx, content)) - }, [setContent]) - - const doUpdateAction = useCallback((component: TacticComponent, action: Action, actionIndex: number) => { - setContent((content) => - updateComponent( - { - ...component, - actions: - component.actions.toSpliced( + const doDeleteAction = useCallback( + (action: Action, idx: number, origin: TacticComponent) => { + setContent((content) => removeAction(origin, action, idx, content)) + }, + [setContent], + ) + + const doUpdateAction = useCallback( + (component: TacticComponent, action: Action, actionIndex: number) => { + setContent((content) => + updateComponent( + { + ...component, + actions: component.actions.toSpliced( actionIndex, 1, action, ), - }, - content, - ), - ) - }, [setContent]) - - const renderComponent = useCallback((component: TacticComponent) => { - if ( - component.type == "player" || - component.type == "phantom" - ) { - return renderPlayer(component) - } - if (component.type == BALL_TYPE) { - return ( - { - setContent((content) => - removeBall(content), - ) - setObjects((objects) => [ - ...objects, - {key: "ball"}, - ]) - }} - /> + }, + content, + ), ) - } - throw new Error( - "unknown tactic component " + component, - ) - }, [renderPlayer, doMoveBall, setContent]) + }, + [setContent], + ) - const renderActions = useCallback((component: TacticComponent) => - component.actions.map((action, i) => { - return ( - { - doDeleteAction(action, i, component) - }} - onActionChanges={(action) => - doUpdateAction(component, action, i) - } - /> - ) - }), [doDeleteAction, doUpdateAction]) + const renderComponent = useCallback( + (component: TacticComponent) => { + if (component.type == "player" || component.type == "phantom") { + return renderPlayer(component) + } + if (component.type == BALL_TYPE) { + return ( + { + setContent((content) => removeBall(content)) + setObjects((objects) => [ + ...objects, + { key: "ball" }, + ]) + }} + /> + ) + } + throw new Error("unknown tactic component " + component) + }, + [renderPlayer, doMoveBall, setContent], + ) + + const renderActions = useCallback( + (component: TacticComponent) => + component.actions.map((action, i) => { + return ( + { + doDeleteAction(action, i, component) + }} + onActionChanges={(action) => + doUpdateAction(component, action, i) + } + /> + ) + }), + [doDeleteAction, doUpdateAction], + ) return (
@@ -392,34 +439,48 @@ function EditorView({ Home
- +
{ - onNameChange(new_name).then((success) => { - setTitleStyle(success ? {} : ERROR_STYLE) - }) - }, [onNameChange])} + onValidated={useCallback( + (new_name) => { + onNameChange(new_name).then((success) => { + setTitleStyle(success ? {} : ERROR_STYLE) + }) + }, + [onNameChange], + )} />
-
+
- + - overlaps(courtBounds(), div.getBoundingClientRect()) - , [courtBounds])} - onElementDetached={useCallback((r, e: RackedCourtObject) => + canDetach={useCallback( + (div) => + overlaps( + courtBounds(), + div.getBoundingClientRect(), + ), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedCourtObject) => setContent((content) => placeObjectAt( r.getBoundingClientRect(), @@ -427,19 +488,25 @@ function EditorView({ e, content, ), - ) - , [courtBounds, setContent])} + ), + [courtBounds, setContent], + )} render={renderCourtObject} /> - +
} + courtImage={} courtRef={courtRef} previewAction={previewAction} renderComponent={renderComponent} @@ -456,23 +523,35 @@ interface PlayerRackProps { id: string objects: RackedPlayer[] setObjects: (state: RackedPlayer[]) => void - setComponents: (f: (components: TacticComponent[]) => TacticComponent[]) => void + setComponents: ( + f: (components: TacticComponent[]) => TacticComponent[], + ) => void courtRef: RefObject } -function PlayerRack({id, objects, setObjects, courtRef, setComponents}: PlayerRackProps) { - - const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) +function PlayerRack({ + id, + objects, + setObjects, + courtRef, + setComponents, +}: PlayerRackProps) { + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) return ( - overlaps(courtBounds(), div.getBoundingClientRect()) - , [courtBounds])} - onElementDetached={useCallback((r, e: RackedPlayer) => + canDetach={useCallback( + (div) => overlaps(courtBounds(), div.getBoundingClientRect()), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedPlayer) => setComponents((components) => [ ...components, placePlayerAt( @@ -480,16 +559,20 @@ function PlayerRack({id, objects, setObjects, courtRef, setComponents}: PlayerRa courtBounds(), e, ), - ]) - , [courtBounds, setComponents])} - render={useCallback(({team, key}: { team: PlayerTeam, key: string }) => ( - - ), [])} + ]), + [courtBounds, setComponents], + )} + render={useCallback( + ({ team, key }: { team: PlayerTeam; key: string }) => ( + + ), + [], + )} /> ) } @@ -506,17 +589,19 @@ interface CourtPlayerArrowActionProps { } function CourtPlayerArrowAction({ - playerInfo, - player, - isInvalid, - - content, - setContent, - setPreviewAction, - courtRef - }: CourtPlayerArrowActionProps) { - - const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, +}: CourtPlayerArrowActionProps) { + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) return ( { - (document.activeElement as HTMLElement).blur() + ;(document.activeElement as HTMLElement).blur() setPreviewAction({ origin: playerInfo.id, type: getActionKind(null, playerInfo.ballState), - target: ratioWithinBase( - headPos, - courtBounds(), - ), + target: ratioWithinBase(headPos, courtBounds()), segments: [ { next: ratioWithinBase( @@ -564,7 +642,7 @@ function CourtPlayerArrowAction({ ), }, ], - isInvalid: false + isInvalid: false, }) }} onHeadDropped={(headRect) => { @@ -574,27 +652,21 @@ function CourtPlayerArrowAction({ } setContent((content) => { - let {createdAction, newContent} = - createAction( - player, - courtBounds(), - headRect, - content, - ) + let { createdAction, newContent } = createAction( + player, + courtBounds(), + headRect, + content, + ) - if ( - createdAction.type == ActionKind.SHOOT - ) { - const targetIdx = - newContent.components.findIndex( - (c) => - c.id == - createdAction.target, - ) + if (createdAction.type == ActionKind.SHOOT) { + const targetIdx = newContent.components.findIndex( + (c) => c.id == createdAction.target, + ) newContent = dropBallOnComponent( targetIdx, newContent, - false + false, ) newContent = updateComponent( { @@ -607,7 +679,6 @@ function CourtPlayerArrowAction({ ) } - return newContent }) setPreviewAction(null) @@ -620,26 +691,28 @@ function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( (c) => - (c.type == "player" && (c.ballState === BallState.HOLDS_ORIGIN || c.ballState === BallState.HOLDS_BY_PASS)) || - c.type == BALL_TYPE, + ((c.type === "player" || c.type === "phantom") && + (c.ballState === BallState.HOLDS_ORIGIN || + c.ballState === BallState.PASSED_ORIGIN)) || + c.type === BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({courtType}: { courtType: string }) { +function Court({ courtType }: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 986854d..c26c0d9 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,8 +1,8 @@ -import {Action, ActionKind} from "../../model/tactic/Action" +import { Action, ActionKind } from "../../model/tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" -import {RefObject} from "react" -import {MoveToHead, ScreenHead} from "../../components/actions/ArrowAction" -import {ComponentId} from "../../model/tactic/Tactic" +import { RefObject } from "react" +import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" +import { ComponentId } from "../../model/tactic/Tactic" export interface CourtActionProps { origin: ComponentId @@ -19,9 +19,8 @@ export function CourtAction({ onActionChanges, onActionDeleted, courtRef, - isInvalid + isInvalid, }: CourtActionProps) { - const color = isInvalid ? "red" : "black" let head @@ -32,7 +31,7 @@ export function CourtAction({ head = () => break case ActionKind.SCREEN: - head = () => + head = () => break } @@ -60,7 +59,7 @@ export function CourtAction({ style={{ head, dashArray, - color + color, }} /> ) From 9a6103e78a5988c6f070a4da988ab8b60411c015 Mon Sep 17 00:00:00 2001 From: maxime Date: Sat, 27 Jan 2024 21:51:05 +0100 Subject: [PATCH 25/44] fix desynchronization when pass arrows are removed --- front/editor/ActionsDomains.ts | 63 ++++++++++++++++++---------- front/editor/TacticContentDomains.ts | 12 +++--- front/views/Editor.tsx | 10 +++-- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index 8bb4200..da988ce 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -20,17 +20,26 @@ import { BALL_TYPE } from "../model/tactic/CourtObjects" export function getActionKind( target: TacticComponent | null, ballState: BallState, -): ActionKind { +): { kind: ActionKind; nextState: BallState } { switch (ballState) { case BallState.HOLDS_ORIGIN: + return target + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN } + : { kind: ActionKind.DRIBBLE, nextState: ballState } case BallState.HOLDS_BY_PASS: - return target ? ActionKind.SHOOT : ActionKind.DRIBBLE + return target + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED } + : { kind: ActionKind.DRIBBLE, nextState: ballState } case BallState.PASSED_ORIGIN: case BallState.PASSED: case BallState.NONE: - return target && target.type != BALL_TYPE - ? ActionKind.SCREEN - : ActionKind.MOVE + return { + kind: + target && target.type != BALL_TYPE + ? ActionKind.SCREEN + : ActionKind.MOVE, + nextState: ballState, + } } } @@ -38,7 +47,7 @@ export function getActionKindBetween( origin: Player | PlayerPhantom, target: TacticComponent | null, state: BallState, -): ActionKind { +): { kind: ActionKind; nextState: BallState } { //remove the target if the target is a phantom that is within the origin's path if ( target != null && @@ -63,9 +72,11 @@ export function isActionValid( // action is invalid if the origin already moves (unless the origin holds a ball which will lead to a ball pass) if ( - origin.actions.find((a) => moves(a.type)) && - origin.ballState != BallState.HOLDS_BY_PASS + origin.ballState != BallState.HOLDS_BY_PASS && + origin.ballState != BallState.HOLDS_ORIGIN && + origin.actions.find((a) => moves(a.type)) ) { + console.log("a") return false } //Action is valid if the target is null @@ -84,6 +95,7 @@ export function isActionValid( (hasBoundWith(target, origin, components) || hasBoundWith(origin, target, components)) ) { + console.log("b") return false } @@ -92,6 +104,7 @@ export function isActionValid( origin.actions.find((a) => a.target === target?.id) || target?.actions.find((a) => a.target === origin.id) ) { + console.log("c") return false } @@ -101,6 +114,7 @@ export function isActionValid( if (areInSamePath(origin, target)) return false if (alreadyHasAnAnteriorActionWith(origin, target, components)) { + console.log("e") return false } } @@ -165,6 +179,7 @@ function alreadyHasAnAnteriorActionWith( phantom.actions.find( (a) => typeof a.target === "string" && + moves(a.type) && originOriginPath.indexOf(a.target) !== -1, ) ) { @@ -181,6 +196,7 @@ function alreadyHasAnAnteriorActionWith( phantom.actions.find( (a) => typeof a.target === "string" && + moves(a.type) && targetOriginPath.indexOf(a.target) > targetIdx, ) ) { @@ -284,7 +300,7 @@ export function createAction( const action: Action = { target: toId, - type: getActionKind(component, origin.ballState), + type: getActionKind(component, origin.ballState).kind, segments: [{ next: toId }], } @@ -305,7 +321,7 @@ export function createAction( const action: Action = { target: phantomId, - type: getActionKind(null, origin.ballState), + type: getActionKind(null, origin.ballState).kind, segments: [{ next: phantomId }], } return { @@ -360,21 +376,22 @@ export function removeAction( action.type == ActionKind.SHOOT && (origin.type === "player" || origin.type === "phantom") ) { - if (origin.ballState === BallState.PASSED) + if (target.type === "player" || target.type === "phantom") + content = changePlayerBallState(target, BallState.NONE, content) + + if (origin.ballState === BallState.PASSED) { content = changePlayerBallState( origin, BallState.HOLDS_BY_PASS, content, ) - else if (origin.ballState === BallState.PASSED_ORIGIN) + } else if (origin.ballState === BallState.PASSED_ORIGIN) { content = changePlayerBallState( origin, BallState.HOLDS_ORIGIN, content, ) - - if (target.type === "player" || target.type === "phantom") - content = changePlayerBallState(target, BallState.NONE, content) + } } if (target.type === "phantom") { @@ -385,7 +402,7 @@ export function removeAction( path = getOrigin(origin, content.components).path } - if (path != null && path.items.find((c) => c == target.id)) { + if (path != null && path.items.find((c) => c === target.id)) { content = removePlayer(target, content) } } @@ -447,7 +464,7 @@ export function spreadNewStateFromOriginStateChange( ) { /// if the new state removes the ball from the player, remove all actions that were meant to shoot the ball deleteAction = true - targetState = BallState.NONE // then remove the ball for the target as well + targetState = BallState.NONE // Then remove the ball for the target as well } else if ( (newState === BallState.HOLDS_BY_PASS || newState === BallState.HOLDS_ORIGIN) && @@ -464,16 +481,18 @@ export function spreadNewStateFromOriginStateChange( i-- // step back } else { // do not change the action type if it is a shoot action - const type = - action.type == ActionKind.SHOOT - ? ActionKind.SHOOT - : getActionKindBetween(origin, actionTarget, newState) + const { kind, nextState } = getActionKindBetween( + origin, + actionTarget, + newState, + ) origin = { ...origin, + ballState: nextState, actions: origin.actions.toSpliced(i, 1, { ...action, - type, + type: kind, }), } content = updateComponent(origin, content) diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index 1c6bfda..4a18439 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -88,12 +88,12 @@ export function dropBallOnComponent( const component = content.components[targetedComponentIdx] if (component.type === "player" || component.type === "phantom") { - const newState = setAsOrigin - ? component.ballState === BallState.PASSED || - component.ballState === BallState.PASSED_ORIGIN - ? BallState.PASSED_ORIGIN - : BallState.HOLDS_ORIGIN - : BallState.HOLDS_BY_PASS + const newState = + setAsOrigin || + component.ballState === BallState.PASSED_ORIGIN || + component.ballState === BallState.HOLDS_ORIGIN + ? BallState.HOLDS_ORIGIN + : BallState.HOLDS_BY_PASS content = changePlayerBallState(component, newState, content) } diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index de7c749..e5ce71b 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -621,7 +621,7 @@ function CourtPlayerArrowAction({ next: ratioWithinBase(arrowHeadPos, courtBounds()), }, ], - type: getActionKind(target, playerInfo.ballState), + type: getActionKind(target, playerInfo.ballState).kind, isInvalid: !overlaps(headPos, courtBounds()) || !isActionValid(player, target, content.components), @@ -632,7 +632,7 @@ function CourtPlayerArrowAction({ setPreviewAction({ origin: playerInfo.id, - type: getActionKind(null, playerInfo.ballState), + type: getActionKind(null, playerInfo.ballState).kind, target: ratioWithinBase(headPos, courtBounds()), segments: [ { @@ -668,12 +668,16 @@ function CourtPlayerArrowAction({ newContent, false, ) + const ballState = + player.ballState === BallState.HOLDS_ORIGIN + ? BallState.PASSED_ORIGIN + : BallState.PASSED newContent = updateComponent( { ...(newContent.components.find( (c) => c.id == player.id, )! as Player | PlayerPhantom), - ballState: BallState.PASSED, + ballState, }, newContent, ) From 87e73a3c5fa5281da2791247bce1a45bbb389838 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Mon, 8 Jan 2024 11:06:52 +0100 Subject: [PATCH 26/44] add manual deployment scripts --- .gitignore | 4 +++- ci/.drone.yml | 6 ++++-- ci/build_and_deploy_to.sh | 17 +++++++++++++++++ ci/build_react.msh | 15 ++++++--------- config.php | 4 ++++ front/views/Editor.tsx | 10 +--------- profiles/dev-config-profile.php | 4 ++++ profiles/prod-config-profile.php | 7 +++++++ public/.htaccess | 4 ++++ public/api/.htaccess | 4 ++++ public/api/index.php | 6 +++--- public/index.php | 3 +-- src/Api/API.php | 2 +- src/Api/Controller/APITacticController.php | 1 + src/App/App.php | 2 +- src/index-utils.php | 21 --------------------- 16 files changed, 61 insertions(+), 49 deletions(-) create mode 100755 ci/build_and_deploy_to.sh create mode 100644 public/.htaccess create mode 100644 public/api/.htaccess delete mode 100644 src/index-utils.php diff --git a/.gitignore b/.gitignore index 3934c5c..e5a863d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,15 @@ vendor .nfs* composer.lock *.phar -/dist +dist .guard +outputs # sqlite database files *.sqlite views-mappings.php +.env.PROD # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. diff --git a/ci/.drone.yml b/ci/.drone.yml index a282766..42b1c42 100644 --- a/ci/.drone.yml +++ b/ci/.drone.yml @@ -37,8 +37,9 @@ steps: - echo n | /tmp/moshell_setup.sh - echo "VITE_API_ENDPOINT=/IQBall/$DRONE_BRANCH/public/api" >> .env.PROD - echo "VITE_BASE=/IQBall/$DRONE_BRANCH/public" >> .env.PROD + - apt update && apt install jq -y - - - /root/.local/bin/moshell ci/build_react.msh + - BASE="/IQBall/$DRONE_BRANCH/public" OUTPUT=/outputs /root/.local/bin/moshell ci/build_react.msh - image: ubuntu:latest name: "prepare php" @@ -48,7 +49,8 @@ steps: 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 + - sed -E -i 's/\\/\\*PROFILE_FILE\\*\\/\\s*".*"/"profiles\\/prod-config-profile.php"/' config.php + - sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\\/IQBall\\/$DRONE_BRANCH\\/public\";/" profiles/prod-config-profile.php - rm profiles/dev-config-profile.php - mv src config.php sql profiles vendor /outputs/ diff --git a/ci/build_and_deploy_to.sh b/ci/build_and_deploy_to.sh new file mode 100755 index 0000000..b0d2924 --- /dev/null +++ b/ci/build_and_deploy_to.sh @@ -0,0 +1,17 @@ + +export OUTPUT=$1 +export BASE=$2 + +rm -rf $OUTPUT/* + +echo "VITE_API_ENDPOINT=$BASE/api" >> .env.PROD +echo "VITE_BASE=$BASE" >> .env.PROD + +ci/build_react.msh + +mkdir -p $OUTPUT/profiles/ + +sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > $OUTPUT/config.php +sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php + +cp -r vendor sql src public $OUTPUT diff --git a/ci/build_react.msh b/ci/build_react.msh index 3d3a8f0..a687ac6 100755 --- a/ci/build_react.msh +++ b/ci/build_react.msh @@ -1,20 +1,17 @@ #!/usr/bin/env moshell -mkdir -p /outputs/public +val base = std::env("BASE").unwrap() +val outputs = std::env("OUTPUT").unwrap() -apt update && apt install jq -y +mkdir -p $outputs/public -val drone_branch = std::env("DRONE_BRANCH").unwrap() - -val base = "/IQBall/$drone_branch/public" npm run build -- --base=$base --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') - -echo ' views-mappings.php +echo ' views-mappings.php while $mappings.len() > 0 { val mapping = $mappings.pop().unwrap(); @@ -28,5 +25,5 @@ echo "];" >> views-mappings.php chmod +r views-mappings.php -mv dist/* front/assets/ front/style/ public/* /outputs/public/ -mv views-mappings.php /outputs/ +cp -r dist/* front/assets/ front/style/ public/* $outputs/public/ +cp -r views-mappings.php $outputs/ diff --git a/config.php b/config.php index 0dd030a..a3871c6 100644 --- a/config.php +++ b/config.php @@ -18,6 +18,10 @@ function asset(string $assetURI): string { return _asset($assetURI); } +function get_base_path(): string { + return _get_base_path(); +} + global $_data_source_name; $data_source_name = $_data_source_name; const DATABASE_USER = _DATABASE_USER; diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index cbb2da5..7dfea4f 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -121,15 +121,7 @@ function EditorView({ const [content, setContent, saveState] = useContentState( initialContent, isInGuestMode ? SaveStates.Guest : SaveStates.Ok, - useMemo( - () => - debounceAsync( - (content) => - onContentChange(content).then((success) => - success ? SaveStates.Ok : SaveStates.Err, - ), - 250, - ), + useMemo(() => debounceAsync(onContentChange), [onContentChange], ), ) diff --git a/profiles/dev-config-profile.php b/profiles/dev-config-profile.php index e39f2f0..0c06513 100644 --- a/profiles/dev-config-profile.php +++ b/profiles/dev-config-profile.php @@ -30,3 +30,7 @@ function _init_database(PDO $pdo): void { $accounts->setIsAdmin($id, true); } } + +function _get_base_path(): string { + return ""; +} diff --git a/profiles/prod-config-profile.php b/profiles/prod-config-profile.php index 185541a..224f8de 100644 --- a/profiles/prod-config-profile.php +++ b/profiles/prod-config-profile.php @@ -4,6 +4,9 @@ // in an `ASSETS` array constant. require __DIR__ . "/../views-mappings.php"; +// THIS VALUE IS TO SET IN THE CI +const BASE_PATH = null; + const _SUPPORTS_FAST_REFRESH = false; $database_file = __DIR__ . "/../database.sqlite"; $_data_source_name = "sqlite:/$database_file"; @@ -23,3 +26,7 @@ function _asset(string $assetURI): string { function _init_database(PDO $pdo): void {} + +function _get_base_path(): string { + return BASE_PATH; +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..36a836b --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^.*$ ./index.php [NC,L,QSA] \ No newline at end of file diff --git a/public/api/.htaccess b/public/api/.htaccess new file mode 100644 index 0000000..36a836b --- /dev/null +++ b/public/api/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^.*$ ./index.php [NC,L,QSA] \ No newline at end of file diff --git a/public/api/index.php b/public/api/index.php index 226e8f1..db14194 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -3,7 +3,6 @@ require "../../config.php"; require "../../vendor/autoload.php"; require "../../sql/database.php"; -require "../../src/index-utils.php"; use IQBall\Api\API; use IQBall\Api\Controller\APIAccountsController; @@ -19,7 +18,8 @@ use IQBall\Core\Gateway\TacticInfoGateway; use IQBall\Core\Model\AuthModel; use IQBall\Core\Model\TacticModel; -$basePath = get_public_path(__DIR__); + +$basePath = get_base_path() . "/api"; function getTacticController(): APITacticController { return new APITacticController(new TacticModel(new TacticInfoGateway(new Connection(get_database())))); @@ -41,8 +41,8 @@ function getServerController(): APIServerController { } function getRoutes(): AltoRouter { - $router = new AltoRouter(); global $basePath; + $router = new AltoRouter(); $router->setBasePath($basePath); $router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize())); diff --git a/public/index.php b/public/index.php index 82dd37f..5d93c4c 100644 --- a/public/index.php +++ b/public/index.php @@ -4,7 +4,6 @@ require "../vendor/autoload.php"; require "../config.php"; require "../sql/database.php"; require "../src/App/react-display.php"; -require "../src/index-utils.php"; use IQBall\App\App; use IQBall\App\Controller\AuthController; @@ -125,6 +124,6 @@ function runMatch($match, MutableSessionHandle $session): HttpResponse { } //this is a global variable -$basePath = get_public_path(__DIR__); +$basePath = get_base_path(); App::render(runMatch(getRoutes()->match(), PhpSessionHandle::init()), fn() => getTwig()); diff --git a/src/Api/API.php b/src/Api/API.php index cc61c8d..143a838 100644 --- a/src/Api/API.php +++ b/src/Api/API.php @@ -38,7 +38,7 @@ class API { */ public static function handleMatch($match, callable $tryGetAuthorization): HttpResponse { if (!$match) { - return new JsonHttpResponse([ValidationFail::notFound("not found")]); + return new JsonHttpResponse([ValidationFail::notFound("not found")], HttpCodes::NOT_FOUND); } $action = $match['target']; diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index 9f71212..3cfe02e 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -49,6 +49,7 @@ class APITacticController { /** * @param int $id + * @param Account $account * @return HttpResponse */ public function saveContent(int $id, Account $account): HttpResponse { diff --git a/src/App/App.php b/src/App/App.php index 5f208bc..1cfe6d7 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -84,7 +84,7 @@ class App { 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 HttpResponse::redirectAbsolute($authRoute); } if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { diff --git a/src/index-utils.php b/src/index-utils.php deleted file mode 100644 index eb600bc..0000000 --- a/src/index-utils.php +++ /dev/null @@ -1,21 +0,0 @@ - Date: Wed, 31 Jan 2024 19:31:43 +0100 Subject: [PATCH 27/44] Add Administration API for teams's administration panel (#97) Co-authored-by: Override-6 Co-authored-by: samuel Co-authored-by: sam Co-authored-by: sam Reviewed-on: https://codefirst.iut.uca.fr/git/IQBall/Application-Web/pulls/97 Co-authored-by: Samuel BERION Co-committed-by: Samuel BERION --- profiles/dev-config-profile.php | 7 ++ public/api/index.php | 15 +++- sql/database.php | 4 + src/Api/API.php | 1 + src/Api/Controller/APIAccountsController.php | 3 +- src/Api/Controller/APITacticController.php | 1 + src/Api/Controller/APITeamController.php | 79 ++++++++++++++++++++ src/App/App.php | 2 +- src/Core/Control.php | 2 + src/Core/Data/User.php | 2 +- src/Core/Gateway/AccountGateway.php | 3 +- src/Core/Gateway/MemberGateway.php | 1 + src/Core/Gateway/TeamGateway.php | 45 +++++++++++ src/Core/Model/TeamModel.php | 32 ++++++-- 14 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 src/Api/Controller/APITeamController.php diff --git a/profiles/dev-config-profile.php b/profiles/dev-config-profile.php index 0c06513..b8a50be 100644 --- a/profiles/dev-config-profile.php +++ b/profiles/dev-config-profile.php @@ -21,14 +21,21 @@ function _asset(string $assetURI): string { function _init_database(PDO $pdo): void { $accounts = new AccountGateway(new Connection($pdo)); + $teams = new \IQBall\Core\Gateway\TeamGateway((new Connection($pdo))); $defaultAccounts = ["maxime", "mael", "yanis", "vivien"]; + $defaultTeams = ["Lakers", "Celtics", "Bulls"]; + foreach ($defaultAccounts as $name) { $email = "$name@mail.com"; $id = $accounts->insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT), "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"); $accounts->setIsAdmin($id, true); } + + foreach ($defaultTeams as $name) { + $id = $teams->insert($name, "https://lebasketographe.fr/wp-content/uploads/2019/11/nom-equipes-nba.jpg", "#1a2b3c", "#FF00AA"); + } } function _get_base_path(): string { diff --git a/public/api/index.php b/public/api/index.php index db14194..be2e8a3 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -33,6 +33,7 @@ function getAccountController(): APIAccountsController { $con = new Connection(get_database()); $gw = new AccountGateway($con); return new APIAccountsController(new AuthModel($gw), $gw); + } function getServerController(): APIServerController { @@ -40,6 +41,13 @@ function getServerController(): APIServerController { return new APIServerController($basePath, get_database()); } +function getAPITeamController(): \IQBall\Api\Controller\APITeamController { + $con = new Connection(get_database()); + return new \IQBall\Api\Controller\APITeamController(new \IQBall\Core\Model\TeamModel(new \IQBall\Core\Gateway\TeamGateway($con), new \IQBall\Core\Gateway\MemberGateway($con), new AccountGateway($con))); +} + + + function getRoutes(): AltoRouter { global $basePath; $router = new AltoRouter(); @@ -48,7 +56,6 @@ function getRoutes(): AltoRouter { $router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize())); $router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); $router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc))); - $router->map("GET", "/admin/list-users", Action::noAuth(fn() => getAccountController()->listUsers($_GET))); $router->map("GET", "/admin/user/[i:id]", Action::noAuth(fn(int $id) => getAccountController()->getUser($id))); $router->map("GET", "/admin/user/[i:id]/space", Action::noAuth(fn(int $id) => getTacticController()->getUserTactics($id))); @@ -56,6 +63,12 @@ function getRoutes(): AltoRouter { $router->map("POST", "/admin/user/remove-all", Action::noAuth(fn() => getAccountController()->removeUsers())); $router->map("POST", "/admin/user/[i:id]/update", Action::noAuth(fn(int $id) => getAccountController()->updateUser($id))); $router->map("GET", "/admin/server-info", Action::noAuth(fn() => getServerController()->getServerInfo())); + $router->map("GET", "/admin/list-team", Action::noAuth(fn() => getAPITeamController()->listTeams($_GET))); + $router->map("POST", "/admin/add-team", Action::noAuth(fn() => getAPITeamController()->addTeam())); + $router->map("POST", "/admin/delete-teams", Action::noAuth(fn() => getAPITeamController()->deleteTeamSelected())); + $router->map("POST", "/admin/team/[i:id]/update", Action::noAuth(fn(int $id) => getAPITeamController()->updateTeam($id))); + + return $router; } diff --git a/sql/database.php b/sql/database.php index 69b53e7..6d20c56 100644 --- a/sql/database.php +++ b/sql/database.php @@ -1,5 +1,9 @@ getHeaders() as $header => $value) { header("$header: $value"); } diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php index 32fd956..6ebf0fc 100644 --- a/src/Api/Controller/APIAccountsController.php +++ b/src/Api/Controller/APIAccountsController.php @@ -3,7 +3,6 @@ namespace IQBall\Api\Controller; use IQBall\Api\APIControl; -use IQBall\App\Control; use IQBall\Core\Data\Account; use IQBall\Core\Gateway\AccountGateway; use IQBall\Core\Http\HttpCodes; @@ -25,6 +24,7 @@ class APIAccountsController { public function __construct(AuthModel $model, AccountGateway $accounts) { $this->accounts = $accounts; $this->authModel = $model; + } @@ -34,6 +34,7 @@ class APIAccountsController { */ public function listUsers(array $request): HttpResponse { return APIControl::runCheckedFrom($request, [ + 'start' => [DefaultValidators::isUnsignedInteger()], 'n' => [DefaultValidators::isIntInRange(0, 250)], 'search' => [DefaultValidators::lenBetween(0, 256)], diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index 3cfe02e..eb00ff5 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -77,6 +77,7 @@ class APITacticController { 'name' => $t->getName(), 'court' => $t->getCourtType(), 'creation_date' => $t->getCreationDate(), + ], $tactics); return new JsonHttpResponse($response); diff --git a/src/Api/Controller/APITeamController.php b/src/Api/Controller/APITeamController.php new file mode 100644 index 0000000..270468d --- /dev/null +++ b/src/Api/Controller/APITeamController.php @@ -0,0 +1,79 @@ +teamModel = $teamModel; + } + + /** + * @param array $req_params + * @return HttpResponse + */ + public function listTeams(array $req_params): HttpResponse { + return APIControl::runCheckedFrom($req_params, [ + 'start' => [DefaultValidators::isUnsignedInteger()], + 'n' => [DefaultValidators::isUnsignedInteger()], + ], function (HttpRequest $req) { + $teams = $this->teamModel->listAll(intval($req['start']), intval($req['n'])); + return new JsonHttpResponse([ + "totalCount" => $this->teamModel->countTeam(), + "teams" => $teams, + ]); + }); + } + + public function addTeam(): HttpResponse { + return APIControl::runChecked([ + 'name' => [DefaultValidators::name()], + 'picture' => [DefaultValidators::isURL()], + 'mainColor' => [DefaultValidators::hexColor()], + 'secondaryColor' => [DefaultValidators::hexColor()], + + ], function (HttpRequest $req) { + $this->teamModel->createTeam($req['name'], $req['picture'], $req['mainColor'], $req['secondaryColor']); + return HttpResponse::fromCode(HttpCodes::OK); + }); + } + + public function deleteTeamSelected(): HttpResponse { + return APIControl::runChecked([ + 'teams' => [], + ], function (HttpRequest $req) { + $this->teamModel->deleteTeamSelected($req['teams']); + return HttpResponse::fromCode(HttpCodes::OK); + }); + } + + public function updateTeam(int $id): HttpResponse { + return APIControl::runChecked([ + 'name' => [DefaultValidators::name()], + 'picture' => [DefaultValidators::isURL()], + 'mainColor' => [DefaultValidators::hexColor()], + 'secondaryColor' => [DefaultValidators::hexColor()], + ], function (HttpRequest $req) { + $this->teamModel->editTeam($req['id'], $req['name'], $req['picture'], $req['mainColor'], $req['secondaryColor']); + return HttpResponse::fromCode(HttpCodes::OK); + }); + } + + +} diff --git a/src/App/App.php b/src/App/App.php index 1cfe6d7..557ad13 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -12,7 +12,6 @@ use Twig\Environment; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; -use Twig\Loader\FilesystemLoader; class App { /** @@ -90,6 +89,7 @@ class App { if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); } + } return $action->run($params, $session); diff --git a/src/Core/Control.php b/src/Core/Control.php index 106052d..51d6622 100644 --- a/src/Core/Control.php +++ b/src/Core/Control.php @@ -24,6 +24,7 @@ class Control { if (!$payload_obj instanceof \stdClass) { $fail = new ValidationFail("bad-payload", "request body is not a valid json object"); return $errorFactory->apply([$fail]); + } $payload = get_object_vars($payload_obj); return self::runCheckedFrom($payload, $schema, $run, $errorFactory); @@ -44,6 +45,7 @@ class Control { if (!empty($fails)) { return $errorFactory->apply($fails); + } return call_user_func_array($run, [$request]); diff --git a/src/Core/Data/User.php b/src/Core/Data/User.php index 02a44c0..8471929 100644 --- a/src/Core/Data/User.php +++ b/src/Core/Data/User.php @@ -24,7 +24,7 @@ class User implements \JsonSerializable { private string $profilePicture; /** - * @var bool isAdmin + * @var bool true if the user is an administrator */ private bool $isAdmin; diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index 6752b01..1a0c689 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -47,7 +47,6 @@ class AccountGateway { return !empty($result); } - /** * promote or demote a user to server administrator * @param int $id @@ -60,6 +59,7 @@ class AccountGateway { } else { $stmnt = $this->con->prepare("DELETE FROM Admins WHERE id = :id"); } + $stmnt->bindValue(':id', $id); $stmnt->execute(); @@ -155,6 +155,7 @@ class AccountGateway { ] ); return array_map(fn(array $acc) => new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))), $res); + } /** diff --git a/src/Core/Gateway/MemberGateway.php b/src/Core/Gateway/MemberGateway.php index 98d2d41..f79ff60 100644 --- a/src/Core/Gateway/MemberGateway.php +++ b/src/Core/Gateway/MemberGateway.php @@ -47,6 +47,7 @@ class MemberGateway { ] ); return array_map(fn($row) => new Member(new User($row['email'], $row['username'], $row['id'], $row['profile_picture'], $row['is_admin']), $teamId, $row['role']), $rows); + } /** diff --git a/src/Core/Gateway/TeamGateway.php b/src/Core/Gateway/TeamGateway.php index a817687..4309a49 100644 --- a/src/Core/Gateway/TeamGateway.php +++ b/src/Core/Gateway/TeamGateway.php @@ -3,6 +3,8 @@ namespace IQBall\Core\Gateway; use IQBall\Core\Connection; +use IQBall\Core\Data\Color; +use IQBall\Core\Data\Team; use IQBall\Core\Data\TeamInfo; use PDO; @@ -46,6 +48,7 @@ class TeamGateway { "id" => [$id, PDO::PARAM_INT], ] ); + return array_map(fn($row) => new TeamInfo($row['id'], $row['name'], $row['picture'], $row['main_color'], $row['second_color']), $result); } @@ -137,5 +140,47 @@ class TeamGateway { ); } + /** + * @param int $start + * @param int $n + * @return TeamInfo[] + */ + public function listAll(int $start, int $n): array { + $rows = $this->con->fetch( + "SELECT * FROM Team LIMIT :start, :n", + [ + ":start" => [$start, PDO::PARAM_INT], + ":n" => [$n, PDO::PARAM_INT], + ] + ); + return array_map(fn($row) => new TeamInfo($row['id'], $row['name'], $row['picture'], $row['main_color'], $row['second_color']), $rows); + } + + public function countTeam(): int { + $result = $this->con->fetch( + "SELECT count(*) as count FROM Team", + [] + ); + if (empty($result) || !isset($result[0]['count'])) { + return 0; + } + return $result[0]['count']; + } + + /** + * @param array $selectedTeams + * @return void + */ + public function deleteTeamSelected(array $selectedTeams): void { + foreach ($selectedTeams as $team) { + $this->con->exec( + "DELETE FROM TEAM WHERE id=:team", + [ + "team" => [$team, PDO::PARAM_INT], + ] + ); + } + } + } diff --git a/src/Core/Model/TeamModel.php b/src/Core/Model/TeamModel.php index 2bfe36e..b6b7bdd 100644 --- a/src/Core/Model/TeamModel.php +++ b/src/Core/Model/TeamModel.php @@ -45,10 +45,10 @@ class TeamModel { */ public function addMember(string $mail, int $teamId, string $role): int { $user = $this->users->getAccountFromMail($mail); - if($user == null) { + if ($user == null) { return -1; } - if(!$this->members->isMemberOfTeam($teamId, $user->getUser()->getId())) { + if (!$this->members->isMemberOfTeam($teamId, $user->getUser()->getId())) { $this->members->insert($teamId, $user->getUser()->getId(), $role); return 1; } @@ -70,7 +70,7 @@ class TeamModel { * @return Team|null */ public function getTeam(int $idTeam, int $idCurrentUser): ?Team { - if(!$this->members->isMemberOfTeam($idTeam, $idCurrentUser)) { + if (!$this->members->isMemberOfTeam($idTeam, $idCurrentUser)) { return null; } $teamInfo = $this->teams->getTeamById($idTeam); @@ -86,7 +86,7 @@ class TeamModel { */ public function deleteMember(int $idMember, int $teamId): int { $this->members->remove($teamId, $idMember); - if(empty($this->members->getMembersOfTeam($teamId))) { + if (empty($this->members->getMembersOfTeam($teamId))) { $this->teams->deleteTeam($teamId); return -1; } @@ -100,7 +100,7 @@ class TeamModel { * @return int */ public function deleteTeam(string $email, int $idTeam): int { - if($this->members->isCoach($email, $idTeam)) { + if ($this->members->isCoach($email, $idTeam)) { $this->teams->deleteTeam($idTeam); return 0; } @@ -139,4 +139,26 @@ class TeamModel { public function getAll(int $user): array { return $this->teams->getAll($user); } + + /** + * @param int $start + * @param int $n + * @return TeamInfo[] + */ + public function listAll(int $start, int $n) { + return $this->teams->listAll($start, $n); + } + + public function countTeam(): int { + return $this->teams->countTeam(); + } + + /** + * @param array $selectedTeams + * @return void + */ + public function deleteTeamSelected(array $selectedTeams) { + $this->teams->deleteTeamSelected($selectedTeams); + } + } From 1b435d4469082fb1aa687700fc9b39aa43375d17 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 4 Feb 2024 20:19:33 +0100 Subject: [PATCH 28/44] apply suggestions --- front/editor/ActionsDomains.ts | 27 ++++++++++++++------------- front/editor/PlayerDomains.ts | 22 ++++++++++++---------- front/editor/TacticContentDomains.ts | 13 ++++--------- front/model/tactic/Player.ts | 2 ++ front/views/Editor.tsx | 16 ++++++++-------- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index da988ce..ad0c8bd 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,4 +1,9 @@ -import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" +import { + BallState, + Player, + PlayerPhantom, + PlayerLike, +} from "../model/tactic/Player" import { ratioWithinBase } from "../geo/Pos" import { ComponentId, @@ -44,7 +49,7 @@ export function getActionKind( } export function getActionKindBetween( - origin: Player | PlayerPhantom, + origin: PlayerLike, target: TacticComponent | null, state: BallState, ): { kind: ActionKind; nextState: BallState } { @@ -76,7 +81,6 @@ export function isActionValid( origin.ballState != BallState.HOLDS_ORIGIN && origin.actions.find((a) => moves(a.type)) ) { - console.log("a") return false } //Action is valid if the target is null @@ -95,7 +99,6 @@ export function isActionValid( (hasBoundWith(target, origin, components) || hasBoundWith(origin, target, components)) ) { - console.log("b") return false } @@ -104,7 +107,6 @@ export function isActionValid( origin.actions.find((a) => a.target === target?.id) || target?.actions.find((a) => a.target === origin.id) ) { - console.log("c") return false } @@ -114,7 +116,6 @@ export function isActionValid( if (areInSamePath(origin, target)) return false if (alreadyHasAnAnteriorActionWith(origin, target, components)) { - console.log("e") return false } } @@ -152,8 +153,8 @@ function hasBoundWith( } function alreadyHasAnAnteriorActionWith( - origin: Player | PlayerPhantom, - target: Player | PlayerPhantom, + origin: PlayerLike, + target: PlayerLike, components: TacticComponent[], ): boolean { const targetOrigin = @@ -174,7 +175,7 @@ function alreadyHasAnAnteriorActionWith( for (let i = targetIdx; i < targetOriginPath.length; i++) { const phantom = components.find( (c) => c.id === targetOriginPath[i], - )! as Player | PlayerPhantom + )! as PlayerLike if ( phantom.actions.find( (a) => @@ -191,7 +192,7 @@ function alreadyHasAnAnteriorActionWith( for (let i = 0; i <= originIdx; i++) { const phantom = components.find( (c) => c.id === originOriginPath[i], - )! as Player | PlayerPhantom + )! as PlayerLike if ( phantom.actions.find( (a) => @@ -208,7 +209,7 @@ function alreadyHasAnAnteriorActionWith( } export function createAction( - origin: Player | PlayerPhantom, + origin: PlayerLike, courtBounds: DOMRect, arrowHead: DOMRect, content: TacticContent, @@ -418,7 +419,7 @@ export function removeAction( * @param content */ export function spreadNewStateFromOriginStateChange( - origin: Player | PlayerPhantom, + origin: PlayerLike, newState: BallState, content: TacticContent, ): TacticContent { @@ -441,7 +442,7 @@ export function spreadNewStateFromOriginStateChange( const actionTarget = content.components.find( (c) => action.target === c.id, - )! as Player | PlayerPhantom + )! as PlayerLike let targetState: BallState = actionTarget.ballState let deleteAction = false diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts index 39419a2..b20ca9d 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,4 +1,9 @@ -import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" +import { + BallState, + Player, + PlayerLike, + PlayerPhantom, +} from "../model/tactic/Player" import { TacticComponent, TacticContent } from "../model/tactic/Tactic" import { removeComponent, updateComponent } from "./TacticContentDomains" import { @@ -15,10 +20,7 @@ export function getOrigin( return components.find((c) => c.id == pathItem.originPlayerId)! as Player } -export function areInSamePath( - a: Player | PlayerPhantom, - b: Player | PlayerPhantom, -) { +export function areInSamePath(a: PlayerLike, b: PlayerLike) { if (a.type === "phantom" && b.type === "phantom") { return a.originPlayerId === b.originPlayerId } @@ -38,8 +40,8 @@ export function areInSamePath( * @returns true if the `other` player is the phantom next-to the origin's path. */ export function isNextInPath( - origin: Player | PlayerPhantom, - other: Player | PlayerPhantom, + origin: PlayerLike, + other: PlayerLike, components: TacticComponent[], ): boolean { if (origin.type === "player") { @@ -74,7 +76,7 @@ export function removePlayerPath( } export function removePlayer( - player: Player | PlayerPhantom, + player: PlayerLike, content: TacticContent, ): TacticContent { content = removeAllActionsTargeting(player.id, content) @@ -93,7 +95,7 @@ export function removePlayer( } const actionTarget = content.components.find( (c) => c.id === action.target, - )! as Player | PlayerPhantom + )! as PlayerLike return spreadNewStateFromOriginStateChange( actionTarget, BallState.NONE, @@ -139,7 +141,7 @@ export function truncatePlayerPath( } export function changePlayerBallState( - player: Player | PlayerPhantom, + player: PlayerLike, newState: BallState, content: TacticContent, ): TacticContent { diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index 4a18439..5839bee 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -188,13 +188,9 @@ export function removeComponent( componentId: ComponentId, content: TacticContent, ): TacticContent { - const componentIdx = content.components.findIndex( - (c) => c.id == componentId, - ) - return { ...content, - components: content.components.toSpliced(componentIdx, 1), + components: content.components.filter((c) => c.id !== componentId), } } @@ -202,12 +198,11 @@ export function updateComponent( component: TacticComponent, content: TacticContent, ): TacticContent { - const componentIdx = content.components.findIndex( - (c) => c.id == component.id, - ) return { ...content, - components: content.components.toSpliced(componentIdx, 1, component), + components: content.components.map((c) => + c.id === component.id ? component : c, + ), } } diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index ad95b2c..a257103 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -2,6 +2,8 @@ import { Component, ComponentId } from "./Tactic" export type PlayerId = string +export type PlayerLike = Player | PlayerPhantom + export enum PlayerTeam { Allies = "allies", Opponents = "opponents", diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index e5ce71b..d65d8a6 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -46,7 +46,7 @@ import { BallState, Player, PlayerInfo, - PlayerPhantom, + PlayerLike, PlayerTeam, } from "../model/tactic/Player" import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" @@ -216,7 +216,7 @@ function EditorView({ } const doRemovePlayer = useCallback( - (component: Player | PlayerPhantom) => { + (component: PlayerLike) => { setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, @@ -224,7 +224,7 @@ function EditorView({ ) const doMoveBall = useCallback( - (newBounds: DOMRect, from?: Player | PlayerPhantom) => { + (newBounds: DOMRect, from?: PlayerLike) => { setContent((content) => { if (from) { content = changePlayerBallState( @@ -243,7 +243,7 @@ function EditorView({ ) const validatePlayerPosition = useCallback( - (player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => { + (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { setContent((content) => moveComponent( newPos, @@ -263,7 +263,7 @@ function EditorView({ ) const renderAvailablePlayerActions = useCallback( - (info: PlayerInfo, player: Player | PlayerPhantom) => { + (info: PlayerInfo, player: PlayerLike) => { let canPlaceArrows: boolean if (player.type == "player") { @@ -317,7 +317,7 @@ function EditorView({ ) const renderPlayer = useCallback( - (component: Player | PlayerPhantom) => { + (component: PlayerLike) => { let info: PlayerInfo const isPhantom = component.type == "phantom" if (isPhantom) { @@ -579,7 +579,7 @@ function PlayerRack({ interface CourtPlayerArrowActionProps { playerInfo: PlayerInfo - player: Player | PlayerPhantom + player: PlayerLike isInvalid: boolean content: TacticContent @@ -676,7 +676,7 @@ function CourtPlayerArrowAction({ { ...(newContent.components.find( (c) => c.id == player.id, - )! as Player | PlayerPhantom), + )! as PlayerLike), ballState, }, newContent, From 4f293ae5dd5d9796f5f131cc1d8d877470e331bb Mon Sep 17 00:00:00 2001 From: maxime Date: Fri, 9 Feb 2024 19:29:28 +0100 Subject: [PATCH 29/44] fix preflight requests --- public/api/index.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/public/api/index.php b/public/api/index.php index be2e8a3..4c5eb50 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -9,14 +9,20 @@ use IQBall\Api\Controller\APIAccountsController; use IQBall\Api\Controller\APIAuthController; use IQBall\Api\Controller\APIServerController; use IQBall\Api\Controller\APITacticController; +use IQBall\Api\Controller\APITeamController; 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\MemberGateway; use IQBall\Core\Gateway\TacticInfoGateway; +use IQBall\Core\Gateway\TeamGateway; use IQBall\Core\Model\AuthModel; use IQBall\Core\Model\TacticModel; +use IQBall\Core\Model\TeamModel; +use IQBall\Core\Http\HttpResponse; +use IQBall\Core\Http\HttpCodes; $basePath = get_base_path() . "/api"; @@ -41,18 +47,18 @@ function getServerController(): APIServerController { return new APIServerController($basePath, get_database()); } -function getAPITeamController(): \IQBall\Api\Controller\APITeamController { +function getAPITeamController(): APITeamController { $con = new Connection(get_database()); - return new \IQBall\Api\Controller\APITeamController(new \IQBall\Core\Model\TeamModel(new \IQBall\Core\Gateway\TeamGateway($con), new \IQBall\Core\Gateway\MemberGateway($con), new AccountGateway($con))); + return new APITeamController(new TeamModel(new TeamGateway($con), new MemberGateway($con), new AccountGateway($con))); } - - function getRoutes(): AltoRouter { global $basePath; $router = new AltoRouter(); $router->setBasePath($basePath); + $router->map("OPTIONS", "*", Action::noAuth(fn() => HttpResponse::fromCode(HttpCodes::OK))); + $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))); From 21b8cad60f22cca6fcccb0ab396fdf47a853b72d Mon Sep 17 00:00:00 2001 From: maxime Date: Fri, 9 Feb 2024 19:41:38 +0100 Subject: [PATCH 30/44] keep fields values on login/register fail --- front/views/Editor.tsx | 4 +--- public/api/index.php | 5 +---- src/App/Controller/AuthController.php | 14 ++++++-------- src/App/Views/display_login.html.twig | 5 +++-- src/App/Views/display_register.html.twig | 6 +++--- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 2ee71d1..598f94a 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -146,9 +146,7 @@ function EditorView({ const [content, setContent, saveState] = useContentState( initialContent, isInGuestMode ? SaveStates.Guest : SaveStates.Ok, - useMemo(() => debounceAsync(onContentChange), - [onContentChange], - ), + useMemo(() => debounceAsync(onContentChange), [onContentChange]), ) const [allies, setAllies] = useState(() => diff --git a/public/api/index.php b/public/api/index.php index 4c5eb50..9648ff5 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -24,7 +24,6 @@ use IQBall\Core\Model\TeamModel; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\HttpCodes; - $basePath = get_base_path() . "/api"; function getTacticController(): APITacticController { @@ -58,7 +57,7 @@ function getRoutes(): AltoRouter { $router->setBasePath($basePath); $router->map("OPTIONS", "*", Action::noAuth(fn() => HttpResponse::fromCode(HttpCodes::OK))); - + $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))); @@ -74,8 +73,6 @@ function getRoutes(): AltoRouter { $router->map("POST", "/admin/delete-teams", Action::noAuth(fn() => getAPITeamController()->deleteTeamSelected())); $router->map("POST", "/admin/team/[i:id]/update", Action::noAuth(fn(int $id) => getAPITeamController()->updateTeam($id))); - - return $router; } diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php index efb8862..27b268a 100644 --- a/src/App/Controller/AuthController.php +++ b/src/App/Controller/AuthController.php @@ -27,13 +27,13 @@ class AuthController { /** * registers given account - * @param mixed[] $request + * @param mixed[] $requestData * @param MutableSessionHandle $session * @return HttpResponse */ - public function register(array $request, MutableSessionHandle $session): HttpResponse { + public function register(array $requestData, MutableSessionHandle $session): HttpResponse { $fails = []; - $request = HttpRequest::from($request, $fails, [ + $request = HttpRequest::from($requestData, $fails, [ "username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)], "password" => [DefaultValidators::password()], "confirmpassword" => [DefaultValidators::password()], @@ -41,9 +41,7 @@ class AuthController { ]); if (!empty($fails)) { - if (!(in_array($request['username'], $fails)) or !(in_array($request['email'], $fails))) { - return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails,'username' => $request['username'],'email' => $request['email']]); - } + return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails, 'username' => $requestData['username'], 'email' => $requestData['email'], 'password' => $requestData['password'], 'confirmpassword' => $requestData['confirmpassword'], 'accept' => true]); } if ($request["password"] != $request['confirmpassword']) { @@ -57,7 +55,7 @@ class AuthController { } if (!empty($fails)) { - return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]); + return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails, 'password' => $requestData['password'], 'confirmpassword' => $requestData['confirmpassword'], 'accept' => true]); } $session->setAccount($account); @@ -84,7 +82,7 @@ class AuthController { $fails = []; $account = $this->model->login($request['email'], $request['password'], $fails); if (!empty($fails)) { - return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails]); + return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails, 'password' => $request['password'], 'email' => $request['email']]); } $session->setAccount($account); diff --git a/src/App/Views/display_login.html.twig b/src/App/Views/display_login.html.twig index c609537..6e2d063 100644 --- a/src/App/Views/display_login.html.twig +++ b/src/App/Views/display_login.html.twig @@ -93,14 +93,15 @@ {% endfor %} - + - + Vous n'avez pas de compte ?

+
diff --git a/src/App/Views/display_register.html.twig b/src/App/Views/display_register.html.twig index 7b883a5..1f5a9c9 100644 --- a/src/App/Views/display_register.html.twig +++ b/src/App/Views/display_register.html.twig @@ -101,13 +101,13 @@ - + - +


Vous avez déjà un compte ? From ff759ade5404f7f999b4beaecd60424bce013a0f Mon Sep 17 00:00:00 2001 From: maxime Date: Fri, 9 Feb 2024 20:15:17 +0100 Subject: [PATCH 31/44] fix ci --- ci/.drone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/.drone.yml b/ci/.drone.yml index 42b1c42..fc0d55b 100644 --- a/ci/.drone.yml +++ b/ci/.drone.yml @@ -50,7 +50,7 @@ steps: - mkdir -p /outputs/public # this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file. - sed -E -i 's/\\/\\*PROFILE_FILE\\*\\/\\s*".*"/"profiles\\/prod-config-profile.php"/' config.php - - sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\\/IQBall\\/$DRONE_BRANCH\\/public\";/" profiles/prod-config-profile.php + - sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\\/IQBall\\/$DRONE_BRANCH\\/public\";/" profiles/prod-config-profile.php - rm profiles/dev-config-profile.php - mv src config.php sql profiles vendor /outputs/ From 0497f79a117be69909c73a067fed456243ab6df6 Mon Sep 17 00:00:00 2001 From: maxime Date: Mon, 12 Feb 2024 11:21:12 +0100 Subject: [PATCH 32/44] fix user update --- src/Api/Controller/APIAccountsController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php index 6ebf0fc..fd7e88e 100644 --- a/src/Api/Controller/APIAccountsController.php +++ b/src/Api/Controller/APIAccountsController.php @@ -34,7 +34,6 @@ class APIAccountsController { */ public function listUsers(array $request): HttpResponse { return APIControl::runCheckedFrom($request, [ - 'start' => [DefaultValidators::isUnsignedInteger()], 'n' => [DefaultValidators::isIntInRange(0, 250)], 'search' => [DefaultValidators::lenBetween(0, 256)], @@ -97,8 +96,9 @@ class APIAccountsController { "username" => [DefaultValidators::name()], "isAdmin" => [DefaultValidators::bool()], ], function (HttpRequest $req) use ($id) { - $mailAccount = $this->accounts->getAccount($id); - if ($mailAccount->getUser()->getId() != $id) { + $mailAccount = $this->accounts->getAccountFromMail($req["email"]); + + if ($mailAccount != null && $mailAccount->getUser()->getId() != $id) { return new JsonHttpResponse([new ValidationFail("email exists", "The provided mail address already exists for another account.")], HttpCodes::FORBIDDEN); } From 970045446b801256588f53343a885e90d339daae Mon Sep 17 00:00:00 2001 From: maxime Date: Wed, 14 Feb 2024 22:26:41 +0100 Subject: [PATCH 33/44] remove php, the front is now independant and uses react-router to serve the pages --- .eslintrc.cjs | 18 ++ .gitignore | 62 ++--- README.md | 1 - composer.json | 18 -- config.php | 33 --- format.sh | 9 - front/ViewRenderer.tsx | 19 -- front/style/theme/dark.css | 9 - index.html | 13 + package.json | 13 +- phpstan.neon | 12 - profiles/dev-config-profile.php | 43 --- profiles/prod-config-profile.php | 32 --- public/.htaccess | 4 - public/api/.htaccess | 4 - public/api/index.php | 99 ------- public/assets | 1 - public/front | 1 - public/index.php | 129 --------- sql/database.php | 32 --- sql/setup-tables.sql | 50 ---- src/Api/API.php | 65 ----- src/Api/APIControl.php | 45 ---- src/Api/Controller/APIAccountsController.php | 109 -------- src/Api/Controller/APIAuthController.php | 44 ---- src/Api/Controller/APIServerController.php | 45 ---- src/Api/Controller/APITacticController.php | 87 ------- src/Api/Controller/APITeamController.php | 79 ------ src/App.tsx | 37 +++ src/App/App.php | 98 ------- src/App/AppControl.php | 44 ---- src/App/Controller/AuthController.php | 99 ------- src/App/Controller/EditorController.php | 87 ------- src/App/Controller/TeamController.php | 246 ------------------ src/App/Controller/UserController.php | 66 ----- src/App/Controller/VisualizerController.php | 39 --- src/App/Session/MutableSessionHandle.php | 22 -- src/App/Session/PhpSessionHandle.php | 38 --- src/App/Session/SessionHandle.php | 23 -- src/App/Validator/TacticValidator.php | 19 -- src/App/ViewHttpResponse.php | 75 ------ src/App/Views/account_settings.twig | 23 -- src/App/Views/add_member.html.twig | 118 --------- src/App/Views/delete_member.html.twig | 73 ------ src/App/Views/display_auth_confirm.html.twig | 46 ---- src/App/Views/display_login.html.twig | 108 -------- src/App/Views/display_register.html.twig | 123 --------- src/App/Views/display_results.html.twig | 18 -- src/App/Views/display_team.html.twig | 109 -------- src/App/Views/display_teams.html.twig | 61 ----- src/App/Views/edit_team.html.twig | 81 ------ src/App/Views/error.html.twig | 57 ---- src/App/Views/home.twig | 97 ------- src/App/Views/insert_team.html.twig | 81 ------ src/App/Views/list_team_by_name.html.twig | 79 ------ src/App/react-display-file.php | 58 ----- src/App/react-display.php | 13 - {front => src}/Constants.ts | 0 src/Core/Action.php | 70 ----- src/Core/Connection.php | 61 ----- src/Core/Control.php | 53 ---- .../ControlSchemaErrorResponseFactory.php | 14 - src/Core/Data/Account.php | 41 --- src/Core/Data/CourtType.php | 61 ----- src/Core/Data/Member.php | 57 ---- src/Core/Data/TacticInfo.php | 62 ----- src/Core/Data/Team.php | 38 --- src/Core/Data/TeamInfo.php | 50 ---- src/Core/Data/User.php | 84 ------ src/Core/Gateway/AccountGateway.php | 180 ------------- src/Core/Gateway/MemberGateway.php | 101 ------- src/Core/Gateway/TacticInfoGateway.php | 151 ----------- src/Core/Gateway/TeamGateway.php | 186 ------------- src/Core/Http/HttpCodes.php | 18 -- src/Core/Http/HttpRequest.php | 81 ------ src/Core/Http/HttpResponse.php | 65 ----- src/Core/Http/JsonHttpResponse.php | 28 -- src/Core/Model/AuthModel.php | 78 ------ src/Core/Model/TacticModel.php | 117 --------- src/Core/Model/TeamModel.php | 164 ------------ src/Core/Validation/ComposedValidator.php | 26 -- src/Core/Validation/DefaultValidators.php | 146 ----------- src/Core/Validation/FieldValidationFail.php | 42 --- src/Core/Validation/FunctionValidator.php | 21 -- .../Validation/SimpleFunctionValidator.php | 33 --- src/Core/Validation/Validation.php | 29 --- src/Core/Validation/ValidationFail.php | 56 ---- src/Core/Validation/Validator.php | 23 -- {front => src}/Fetcher.ts | 0 {front => src}/assets/account.svg | 0 {front => src}/assets/court/full_court.svg | 0 {front => src}/assets/court/half_court.svg | 0 {front => src}/assets/favicon.ico | Bin {front => src}/assets/icon/account.png | Bin {front => src}/assets/icon/account.svg | 0 src/assets/icon/add.svg | 1 + {front => src}/assets/icon/arrow.svg | 0 {front => src}/assets/icon/ball.svg | 0 {front => src}/assets/icon/remove.svg | 0 {front => src}/assets/logo.svg | 0 {front => src}/assets/logo192.png | Bin {front => src}/assets/logo512.png | Bin src/assets/react.svg | 1 + src/assets/vite.svg | 1 + {front => src}/components/Rack.tsx | 0 {front => src}/components/TitleInput.tsx | 0 .../components/actions/ArrowAction.tsx | 0 .../components/actions/BallAction.tsx | 0 .../components/arrows/BendableArrow.tsx | 0 .../components/editor/BallPiece.tsx | 0 .../components/editor/BasketCourt.tsx | 11 +- .../components}/editor/CourtAction.tsx | 0 .../components/editor/CourtBall.tsx | 0 .../components/editor/CourtPlayer.tsx | 0 .../components/editor/PlayerPiece.tsx | 0 .../components/editor/SavingState.tsx | 0 {front => src}/editor/ActionsDomains.ts | 0 {front => src}/editor/PlayerDomains.ts | 0 {front => src}/editor/RackedItems.ts | 0 .../editor/StepContentDomains.ts | 0 src/editor/TacticContentDomains.ts | 243 +++++++++++++++++ {front => src}/geo/Box.ts | 0 {front => src}/geo/Pos.ts | 0 src/index.css | 25 ++ src/main.tsx | 11 + {front => src}/model/Team.ts | 0 {front => src}/model/User.ts | 0 {front => src}/model/tactic/Action.ts | 0 {front => src}/model/tactic/CourtObjects.ts | 0 {front => src}/model/tactic/Player.ts | 0 {front => src}/model/tactic/Tactic.ts | 0 src/model/tactic/TacticInfo.ts | 39 +++ src/pages/404.tsx | 11 + src/pages/CreateTeamPage.tsx | 4 + {front/views => src/pages}/Editor.tsx | 60 +++-- {front/views => src/pages}/Home.tsx | 13 +- .../pages/NewTacticPage.tsx | 2 +- {front/views => src/pages}/TeamPanel.tsx | 66 +++-- {front/views => src/pages}/Visualizer.tsx | 0 .../views => src/pages}/template/Header.tsx | 2 +- {front => src}/style/actions/arrow_action.css | 0 .../style/actions/remove_action.css | 0 {front => src}/style/ball.css | 0 {front => src}/style/bendable_arrows.css | 0 {front => src}/style/editor.css | 1 + {front => src}/style/home/home.css | 6 +- {front => src}/style/home/personnal_space.css | 2 +- {front => src}/style/home/side_menu.css | 8 +- {front => src}/style/new_tactic_panel.css | 0 {front => src}/style/player.css | 0 src/style/steps_tree.css | 87 +++++++ {front => src}/style/team_panel.css | 0 {front => src}/style/template/header.css | 2 +- {front => src}/style/theme/default.css | 8 + {front => src}/style/title_input.css | 0 {front => src}/style/visualizer.css | 0 src/vite-env.d.ts | 1 + tsconfig.json | 36 ++- tsconfig.node.json | 11 + verify.sh | 9 - vite.config.ts | 56 +--- 161 files changed, 670 insertions(+), 5297 deletions(-) create mode 100644 .eslintrc.cjs delete mode 100644 composer.json delete mode 100644 config.php delete mode 100755 format.sh delete mode 100644 front/ViewRenderer.tsx delete mode 100644 front/style/theme/dark.css create mode 100644 index.html delete mode 100644 phpstan.neon delete mode 100644 profiles/dev-config-profile.php delete mode 100644 profiles/prod-config-profile.php delete mode 100644 public/.htaccess delete mode 100644 public/api/.htaccess delete mode 100644 public/api/index.php delete mode 120000 public/assets delete mode 120000 public/front delete mode 100644 public/index.php delete mode 100644 sql/database.php delete mode 100644 sql/setup-tables.sql delete mode 100644 src/Api/API.php delete mode 100644 src/Api/APIControl.php delete mode 100644 src/Api/Controller/APIAccountsController.php delete mode 100644 src/Api/Controller/APIAuthController.php delete mode 100644 src/Api/Controller/APIServerController.php delete mode 100644 src/Api/Controller/APITacticController.php delete mode 100644 src/Api/Controller/APITeamController.php create mode 100644 src/App.tsx delete mode 100644 src/App/App.php delete mode 100644 src/App/AppControl.php delete mode 100644 src/App/Controller/AuthController.php delete mode 100644 src/App/Controller/EditorController.php delete mode 100644 src/App/Controller/TeamController.php delete mode 100644 src/App/Controller/UserController.php delete mode 100644 src/App/Controller/VisualizerController.php delete mode 100644 src/App/Session/MutableSessionHandle.php delete mode 100644 src/App/Session/PhpSessionHandle.php delete mode 100644 src/App/Session/SessionHandle.php delete mode 100644 src/App/Validator/TacticValidator.php delete mode 100644 src/App/ViewHttpResponse.php delete mode 100644 src/App/Views/account_settings.twig delete mode 100644 src/App/Views/add_member.html.twig delete mode 100644 src/App/Views/delete_member.html.twig delete mode 100644 src/App/Views/display_auth_confirm.html.twig delete mode 100644 src/App/Views/display_login.html.twig delete mode 100644 src/App/Views/display_register.html.twig delete mode 100644 src/App/Views/display_results.html.twig delete mode 100644 src/App/Views/display_team.html.twig delete mode 100644 src/App/Views/display_teams.html.twig delete mode 100644 src/App/Views/edit_team.html.twig delete mode 100644 src/App/Views/error.html.twig delete mode 100644 src/App/Views/home.twig delete mode 100644 src/App/Views/insert_team.html.twig delete mode 100644 src/App/Views/list_team_by_name.html.twig delete mode 100755 src/App/react-display-file.php delete mode 100644 src/App/react-display.php rename {front => src}/Constants.ts (100%) delete mode 100644 src/Core/Action.php delete mode 100644 src/Core/Connection.php delete mode 100644 src/Core/Control.php delete mode 100644 src/Core/ControlSchemaErrorResponseFactory.php delete mode 100755 src/Core/Data/Account.php delete mode 100755 src/Core/Data/CourtType.php delete mode 100755 src/Core/Data/Member.php delete mode 100644 src/Core/Data/TacticInfo.php delete mode 100755 src/Core/Data/Team.php delete mode 100644 src/Core/Data/TeamInfo.php delete mode 100644 src/Core/Data/User.php delete mode 100644 src/Core/Gateway/AccountGateway.php delete mode 100644 src/Core/Gateway/MemberGateway.php delete mode 100644 src/Core/Gateway/TacticInfoGateway.php delete mode 100644 src/Core/Gateway/TeamGateway.php delete mode 100644 src/Core/Http/HttpCodes.php delete mode 100644 src/Core/Http/HttpRequest.php delete mode 100644 src/Core/Http/HttpResponse.php delete mode 100644 src/Core/Http/JsonHttpResponse.php delete mode 100644 src/Core/Model/AuthModel.php delete mode 100644 src/Core/Model/TacticModel.php delete mode 100644 src/Core/Model/TeamModel.php delete mode 100644 src/Core/Validation/ComposedValidator.php delete mode 100644 src/Core/Validation/DefaultValidators.php delete mode 100644 src/Core/Validation/FieldValidationFail.php delete mode 100644 src/Core/Validation/FunctionValidator.php delete mode 100644 src/Core/Validation/SimpleFunctionValidator.php delete mode 100644 src/Core/Validation/Validation.php delete mode 100644 src/Core/Validation/ValidationFail.php delete mode 100644 src/Core/Validation/Validator.php rename {front => src}/Fetcher.ts (100%) rename {front => src}/assets/account.svg (100%) rename {front => src}/assets/court/full_court.svg (100%) rename {front => src}/assets/court/half_court.svg (100%) rename {front => src}/assets/favicon.ico (100%) rename {front => src}/assets/icon/account.png (100%) rename {front => src}/assets/icon/account.svg (100%) create mode 100644 src/assets/icon/add.svg rename {front => src}/assets/icon/arrow.svg (100%) rename {front => src}/assets/icon/ball.svg (100%) rename {front => src}/assets/icon/remove.svg (100%) rename {front => src}/assets/logo.svg (100%) rename {front => src}/assets/logo192.png (100%) rename {front => src}/assets/logo512.png (100%) create mode 100644 src/assets/react.svg create mode 100644 src/assets/vite.svg rename {front => src}/components/Rack.tsx (100%) rename {front => src}/components/TitleInput.tsx (100%) rename {front => src}/components/actions/ArrowAction.tsx (100%) rename {front => src}/components/actions/BallAction.tsx (100%) rename {front => src}/components/arrows/BendableArrow.tsx (100%) rename {front => src}/components/editor/BallPiece.tsx (100%) rename {front => src}/components/editor/BasketCourt.tsx (88%) rename {front/views => src/components}/editor/CourtAction.tsx (100%) rename {front => src}/components/editor/CourtBall.tsx (100%) rename {front => src}/components/editor/CourtPlayer.tsx (100%) rename {front => src}/components/editor/PlayerPiece.tsx (100%) rename {front => src}/components/editor/SavingState.tsx (100%) rename {front => src}/editor/ActionsDomains.ts (100%) rename {front => src}/editor/PlayerDomains.ts (100%) rename {front => src}/editor/RackedItems.ts (100%) rename front/editor/TacticContentDomains.ts => src/editor/StepContentDomains.ts (100%) create mode 100644 src/editor/TacticContentDomains.ts rename {front => src}/geo/Box.ts (100%) rename {front => src}/geo/Pos.ts (100%) create mode 100644 src/index.css create mode 100644 src/main.tsx rename {front => src}/model/Team.ts (100%) rename {front => src}/model/User.ts (100%) rename {front => src}/model/tactic/Action.ts (100%) rename {front => src}/model/tactic/CourtObjects.ts (100%) rename {front => src}/model/tactic/Player.ts (100%) rename {front => src}/model/tactic/Tactic.ts (100%) create mode 100644 src/model/tactic/TacticInfo.ts create mode 100644 src/pages/404.tsx create mode 100644 src/pages/CreateTeamPage.tsx rename {front/views => src/pages}/Editor.tsx (95%) rename {front/views => src/pages}/Home.tsx (95%) rename front/views/NewTacticPanel.tsx => src/pages/NewTacticPage.tsx (97%) rename {front/views => src/pages}/TeamPanel.tsx (72%) rename {front/views => src/pages}/Visualizer.tsx (100%) rename {front/views => src/pages}/template/Header.tsx (95%) rename {front => src}/style/actions/arrow_action.css (100%) rename {front => src}/style/actions/remove_action.css (100%) rename {front => src}/style/ball.css (100%) rename {front => src}/style/bendable_arrows.css (100%) rename {front => src}/style/editor.css (99%) rename {front => src}/style/home/home.css (84%) rename {front => src}/style/home/personnal_space.css (93%) rename {front => src}/style/home/side_menu.css (81%) rename {front => src}/style/new_tactic_panel.css (100%) rename {front => src}/style/player.css (100%) create mode 100644 src/style/steps_tree.css rename {front => src}/style/team_panel.css (100%) rename {front => src}/style/template/header.css (95%) rename {front => src}/style/theme/default.css (74%) rename {front => src}/style/title_input.css (100%) rename {front => src}/style/visualizer.css (100%) create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.node.json delete mode 100755 verify.sh diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/.gitignore b/.gitignore index e5a863d..265f50c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,26 @@ -.vs -.vscode -.idea -.code -.vite +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* -vendor -.nfs* -composer.lock -*.phar +node_modules dist -.guard -outputs - -# sqlite database files -*.sqlite - -views-mappings.php -.env.PROD - -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -package-lock.json +dist-ssr +*.local -# testing -/coverage - -# production -/build - -# misc +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea .DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? -.php-cs-fixer.cache \ No newline at end of file +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 9a0df84..17f2ded 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,3 @@ This repository hosts the IQBall application for web ## Read the docs ! You can find some additional documentation in the [Documentation](Documentation) folder, and in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki). - diff --git a/composer.json b/composer.json deleted file mode 100644 index 1d3a4d7..0000000 --- a/composer.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "autoload": { - "psr-4": { - "IQBall\\": "src/" - } - }, - "require": { - "altorouter/altorouter": "1.2.0", - "ext-json": "*", - "ext-pdo": "*", - "ext-pdo_sqlite": "*", - "twig/twig":"^2.0", - "phpstan/phpstan": "*" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.38" - } -} diff --git a/config.php b/config.php deleted file mode 100644 index a3871c6..0000000 --- a/config.php +++ /dev/null @@ -1,33 +0,0 @@ - - - , - ) -} diff --git a/front/style/theme/dark.css b/front/style/theme/dark.css deleted file mode 100644 index bdd4824..0000000 --- a/front/style/theme/dark.css +++ /dev/null @@ -1,9 +0,0 @@ -:root { - --main-color: #191a21; - --second-color: #282a36; - --third-color: #303341; - --accent-color: #ffa238; - --main-contrast-color: #e6edf3; - --font-title: Helvetica; - --font-content: Helvetica; -} diff --git a/index.html b/index.html new file mode 100644 index 0000000..73324da --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/package.json b/package.json index 966a0e9..9904fe4 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@loadable/component": "^5.16.3", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -10,9 +11,11 @@ "@types/node": "^16.18.59", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", + "eslint-plugin-react-refresh": "^0.4.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.6", + "react-router-dom": "^6.22.0", "typescript": "^5.2.2", "vite": "^4.5.0", "vite-plugin-css-injected-by-js": "^3.3.0" @@ -25,14 +28,14 @@ "tsc": "tsc" }, "devDependencies": { - "@vitejs/plugin-react": "^4.1.0", - "prettier": "^3.1.0", - "typescript": "^5.2.2", - "vite-plugin-svgr": "^4.1.0", "@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/parser": "^6.11.0", + "@vitejs/plugin-react": "^4.1.0", "eslint": "^8.53.0", "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0" + "eslint-plugin-react-hooks": "^4.6.0", + "prettier": "^3.1.0", + "typescript": "^5.2.2", + "vite-plugin-svgr": "^4.1.0" } } diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index 346baaa..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,12 +0,0 @@ -parameters: - phpVersion: 70400 - level: 6 - paths: - - src - scanFiles: - - config.php - - sql/database.php - - profiles/dev-config-profile.php - - profiles/prod-config-profile.php - excludePaths: - - src/App/react-display-file.php diff --git a/profiles/dev-config-profile.php b/profiles/dev-config-profile.php deleted file mode 100644 index b8a50be..0000000 --- a/profiles/dev-config-profile.php +++ /dev/null @@ -1,43 +0,0 @@ -insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT), "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"); - $accounts->setIsAdmin($id, true); - } - - foreach ($defaultTeams as $name) { - $id = $teams->insert($name, "https://lebasketographe.fr/wp-content/uploads/2019/11/nom-equipes-nba.jpg", "#1a2b3c", "#FF00AA"); - } -} - -function _get_base_path(): string { - return ""; -} diff --git a/profiles/prod-config-profile.php b/profiles/prod-config-profile.php deleted file mode 100644 index 224f8de..0000000 --- a/profiles/prod-config-profile.php +++ /dev/null @@ -1,32 +0,0 @@ -setBasePath($basePath); - - $router->map("OPTIONS", "*", Action::noAuth(fn() => HttpResponse::fromCode(HttpCodes::OK))); - - $router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize())); - $router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); - $router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc))); - $router->map("GET", "/admin/list-users", Action::noAuth(fn() => getAccountController()->listUsers($_GET))); - $router->map("GET", "/admin/user/[i:id]", Action::noAuth(fn(int $id) => getAccountController()->getUser($id))); - $router->map("GET", "/admin/user/[i:id]/space", Action::noAuth(fn(int $id) => getTacticController()->getUserTactics($id))); - $router->map("POST", "/admin/user/add", Action::noAuth(fn() => getAccountController()->addUser())); - $router->map("POST", "/admin/user/remove-all", Action::noAuth(fn() => getAccountController()->removeUsers())); - $router->map("POST", "/admin/user/[i:id]/update", Action::noAuth(fn(int $id) => getAccountController()->updateUser($id))); - $router->map("GET", "/admin/server-info", Action::noAuth(fn() => getServerController()->getServerInfo())); - $router->map("GET", "/admin/list-team", Action::noAuth(fn() => getAPITeamController()->listTeams($_GET))); - $router->map("POST", "/admin/add-team", Action::noAuth(fn() => getAPITeamController()->addTeam())); - $router->map("POST", "/admin/delete-teams", Action::noAuth(fn() => getAPITeamController()->deleteTeamSelected())); - $router->map("POST", "/admin/team/[i:id]/update", Action::noAuth(fn(int $id) => getAPITeamController()->updateTeam($id))); - - return $router; -} - -/** - * Defines the way of being authorised through the API - * By checking if an Authorisation header is set, and by expecting its value to be a valid token of an account. - * If the header is not set, fallback to the App's PHP session system, and try to extract the account from it. - * @return Account|null - * @throws Exception - */ -function tryGetAuthorization(): ?Account { - $headers = getallheaders(); - - // If no authorization header is set, try fallback to php session. - if (!isset($headers['Authorization'])) { - $session = PhpSessionHandle::init(); - return $session->getAccount(); - } - $token = $headers['Authorization']; - $gateway = new AccountGateway(new Connection(get_database())); - return $gateway->getAccountFromToken($token); -} - -Api::consume(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization())); diff --git a/public/assets b/public/assets deleted file mode 120000 index 7b299d9..0000000 --- a/public/assets +++ /dev/null @@ -1 +0,0 @@ -../front/assets \ No newline at end of file diff --git a/public/front b/public/front deleted file mode 120000 index c1394c9..0000000 --- a/public/front +++ /dev/null @@ -1 +0,0 @@ -../front \ No newline at end of file diff --git a/public/index.php b/public/index.php deleted file mode 100644 index 5d93c4c..0000000 --- a/public/index.php +++ /dev/null @@ -1,129 +0,0 @@ -addFunction(new TwigFunction('path', fn(string $str) => "$basePath$str")); - - return $twig; -} - -function getRoutes(): AltoRouter { - global $basePath; - - $ar = new AltoRouter(); - $ar->setBasePath($basePath); - - //authentication - $ar->map("GET", "/login", Action::noAuth(fn() => getAuthController()->displayLogin())); - $ar->map("GET", "/register", Action::noAuth(fn() => getAuthController()->displayRegister())); - $ar->map("POST", "/login", Action::noAuth(fn(SessionHandle $s) => getAuthController()->login($_POST, $s))); - $ar->map("POST", "/register", Action::noAuth(fn(SessionHandle $s) => getAuthController()->register($_POST, $s))); - - //user-related - $ar->map("GET", "/", Action::auth(fn(SessionHandle $s) => getUserController()->home($s))); - $ar->map("GET", "/home", Action::auth(fn(SessionHandle $s) => getUserController()->home($s))); - $ar->map("GET", "/settings", Action::auth(fn(SessionHandle $s) => getUserController()->settings($s))); - $ar->map("GET", "/disconnect", Action::auth(fn(MutableSessionHandle $s) => getUserController()->disconnect($s))); - - - //tactic-related - $ar->map("GET", "/tactic/[i:id]/view", Action::auth(fn(int $id, SessionHandle $s) => getVisualizerController()->openVisualizer($id, $s))); - $ar->map("GET", "/tactic/[i:id]/edit", Action::auth(fn(int $id, SessionHandle $s) => getEditorController()->openEditor($id, $s))); - // don't require an authentication to run this action. - // If the user is not connected, the tactic will never save. - $ar->map("GET", "/tactic/new", Action::noAuth(fn() => getEditorController()->createNew())); - $ar->map("GET", "/tactic/new/plain", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNewOfKind(CourtType::plain(), $s))); - $ar->map("GET", "/tactic/new/half", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNewOfKind(CourtType::half(), $s))); - - //team-related - $ar->map("GET", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->displayCreateTeam($s))); - $ar->map("POST", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->submitTeam($_POST, $s))); - $ar->map("GET", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->displayListTeamByName($s))); - $ar->map("POST", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->listTeamByName($_POST, $s))); - $ar->map("GET", "/team/[i:id]", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->displayTeam($id, $s))); - $ar->map("GET", "/team/[i:id]/delete", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->deleteTeamById($id, $s))); - $ar->map("GET", "/team/[i:id]/addMember", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->displayAddMember($id, $s))); - $ar->map("POST", "/team/[i:id]/addMember", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->addMember($id, $_POST, $s))); - $ar->map("GET", "/team/[i:idTeam]/remove/[i:idMember]", Action::auth(fn(int $idTeam, int $idMember, SessionHandle $s) => getTeamController()->deleteMember($idTeam, $idMember, $s))); - $ar->map("GET", "/team/[i:id]/edit", Action::auth(fn(int $idTeam, SessionHandle $s) => getTeamController()->displayEditTeam($idTeam, $s))); - $ar->map("POST", "/team/[i:id]/edit", Action::auth(fn(int $idTeam, SessionHandle $s) => getTeamController()->editTeam($idTeam, $_POST, $s))); - - - return $ar; -} - -function runMatch($match, MutableSessionHandle $session): HttpResponse { - global $basePath; - if (!$match) { - return ViewHttpResponse::twig("error.html.twig", [ - 'failures' => [ValidationFail::notFound("Could not find page {$_SERVER['REQUEST_URI']}.")], - ], HttpCodes::NOT_FOUND); - } - - return App::runAction($basePath . '/login', $match['target'], $match['params'], $session); -} - -//this is a global variable -$basePath = get_base_path(); - -App::render(runMatch(getRoutes()->match(), PhpSessionHandle::init()), fn() => getTwig()); diff --git a/sql/database.php b/sql/database.php deleted file mode 100644 index 7dcd5fc..0000000 --- a/sql/database.php +++ /dev/null @@ -1,32 +0,0 @@ -query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")->fetchColumn() > 0; - - if ($database_exists) { - return $pdo; - } - - foreach (scandir(__DIR__) as $file) { - if (preg_match("/.*\.sql$/i", $file)) { - $content = file_get_contents(__DIR__ . "/" . $file); - - $pdo->exec($content); - } - } - - init_database($pdo); - - return $pdo; -} diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql deleted file mode 100644 index 2971de9..0000000 --- a/sql/setup-tables.sql +++ /dev/null @@ -1,50 +0,0 @@ --- drop tables here -DROP TABLE IF EXISTS Account; -DROP TABLE IF EXISTS Tactic; -DROP TABLE IF EXISTS Team; -DROP TABLE IF EXISTS User; -DROP TABLE IF EXISTS Member; - -CREATE TABLE Admins -( - id integer PRIMARY KEY REFERENCES Account -); - -CREATE TABLE Account -( - id integer PRIMARY KEY AUTOINCREMENT, - email varchar UNIQUE NOT NULL, - username varchar NOT NULL, - token varchar UNIQUE NOT NULL, - hash varchar NOT NULL, - profile_picture varchar NOT NULL -); - -CREATE TABLE Tactic -( - id integer PRIMARY KEY AUTOINCREMENT, - name varchar NOT NULL, - creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, - owner integer NOT NULL, - content varchar DEFAULT '{"components": []}' NOT NULL, - court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL, - FOREIGN KEY (owner) REFERENCES Account -); - -CREATE TABLE Team -( - id integer PRIMARY KEY AUTOINCREMENT NOT NULL, - name varchar NOT NULL, - picture varchar NOT NULL, - main_color varchar NOT NULL, - second_color varchar NOT NULL -); - -CREATE TABLE Member -( - id_team integer NOT NULL, - id_user integer NOT NULL, - role text CHECK (role IN ('COACH', 'PLAYER')) NOT NULL, - FOREIGN KEY (id_team) REFERENCES Team (id), - FOREIGN KEY (id_user) REFERENCES Account (id) -); diff --git a/src/Api/API.php b/src/Api/API.php deleted file mode 100644 index 6893eaa..0000000 --- a/src/Api/API.php +++ /dev/null @@ -1,65 +0,0 @@ -getCode()); - - header('Access-Control-Allow-Origin: *'); - header('Access-Control-Allow-Headers: *'); - - - foreach ($response->getHeaders() as $header => $value) { - header("$header: $value"); - } - - if ($response instanceof JsonHttpResponse) { - header('Content-type: application/json'); - echo $response->getJson(); - } elseif (get_class($response) != HttpResponse::class) { - throw new Exception("API returned unknown Http Response"); - } - } - - - /** - * @param array|false $match - * @param callable(): Account $tryGetAuthorization function to return account authorisation for the given action (if required) - * @return HttpResponse - * @throws Exception - */ - public static function handleMatch($match, callable $tryGetAuthorization): HttpResponse { - if (!$match) { - return new JsonHttpResponse([ValidationFail::notFound("not found")], HttpCodes::NOT_FOUND); - } - - $action = $match['target']; - if (!$action instanceof Action) { - throw new Exception("routed action is not an AppAction object."); - } - - $account = null; - - if ($action->getAuthType() != Action::NO_AUTH) { - $account = call_user_func($tryGetAuthorization); - if ($account == null) { - return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")], HttpCodes::UNAUTHORIZED); - } - - if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { - return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); - } - } - - return $action->run($match['params'], $account); - } -} diff --git a/src/Api/APIControl.php b/src/Api/APIControl.php deleted file mode 100644 index 751fbfb..0000000 --- a/src/Api/APIControl.php +++ /dev/null @@ -1,45 +0,0 @@ - $schema an array of `fieldName => DefaultValidators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * The callback must accept an HttpRequest, and return an HttpResponse object. - * @return HttpResponse - */ - public static function runChecked(array $schema, callable $run): HttpResponse { - return Control::runChecked($schema, $run, self::errorFactory()); - } - - /** - * Runs given callback, if the given request data array validates the given schema. - * @param array $data the request's data array. - * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * The callback must accept an HttpRequest, and return an HttpResponse object. - * @return HttpResponse - */ - public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { - return Control::runCheckedFrom($data, $schema, $run, self::errorFactory()); - } - -} diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php deleted file mode 100644 index fd7e88e..0000000 --- a/src/Api/Controller/APIAccountsController.php +++ /dev/null @@ -1,109 +0,0 @@ -accounts = $accounts; - $this->authModel = $model; - - } - - - /** - * @param array $request - * @return HttpResponse - */ - public function listUsers(array $request): HttpResponse { - return APIControl::runCheckedFrom($request, [ - 'start' => [DefaultValidators::isUnsignedInteger()], - 'n' => [DefaultValidators::isIntInRange(0, 250)], - 'search' => [DefaultValidators::lenBetween(0, 256)], - ], function (HttpRequest $req) { - $accounts = $this->accounts->searchAccounts(intval($req['start']), intval($req['n']), $req["search"]); - $users = array_map(fn(Account $acc) => $acc->getUser(), $accounts); - return new JsonHttpResponse([ - "users" => $users, - "totalCount" => $this->accounts->totalCount(), - ]); - }); - } - - /** - * @param int $userId - * @return HttpResponse given user information. - */ - public function getUser(int $userId): HttpResponse { - $acc = $this->accounts->getAccount($userId); - - if ($acc == null) { - return new JsonHttpResponse([ValidationFail::notFound("User not found")], HttpCodes::NOT_FOUND); - } - - return new JsonHttpResponse($acc->getUser()); - } - - public function addUser(): HttpResponse { - return APIControl::runChecked([ - "username" => [DefaultValidators::name()], - "email" => [DefaultValidators::email()], - "password" => [DefaultValidators::password()], - "isAdmin" => [DefaultValidators::bool()], - ], function (HttpRequest $req) { - $model = new AuthModel($this->accounts); - - $account = $model->register($req["username"], $req["password"], $req["email"]); - if ($account == null) { - return new JsonHttpResponse([new ValidationFail("already exists", "An account with provided email ")], HttpCodes::FORBIDDEN); - } - - return new JsonHttpResponse([ - "id" => $account->getUser()->getId(), - ]); - }); - } - - public function removeUsers(): HttpResponse { - return APIControl::runChecked([ - "identifiers" => [DefaultValidators::array(), DefaultValidators::forall(DefaultValidators::isUnsignedInteger())], - ], function (HttpRequest $req) { - $this->accounts->removeAccounts($req["identifiers"]); - return HttpResponse::fromCode(HttpCodes::OK); - }); - } - - public function updateUser(int $id): HttpResponse { - return APIControl::runChecked([ - "email" => [DefaultValidators::email()], - "username" => [DefaultValidators::name()], - "isAdmin" => [DefaultValidators::bool()], - ], function (HttpRequest $req) use ($id) { - $mailAccount = $this->accounts->getAccountFromMail($req["email"]); - - if ($mailAccount != null && $mailAccount->getUser()->getId() != $id) { - return new JsonHttpResponse([new ValidationFail("email exists", "The provided mail address already exists for another account.")], HttpCodes::FORBIDDEN); - } - - $this->authModel->update($id, $req["email"], $req["username"], $req["isAdmin"]); - return HttpResponse::fromCode(HttpCodes::OK); - }); - } -} diff --git a/src/Api/Controller/APIAuthController.php b/src/Api/Controller/APIAuthController.php deleted file mode 100644 index c715803..0000000 --- a/src/Api/Controller/APIAuthController.php +++ /dev/null @@ -1,44 +0,0 @@ -model = $model; - } - - - /** - * From given email address and password, authenticate the user and respond with its authorization token. - * @return HttpResponse - */ - public function authorize(): HttpResponse { - return APIControl::runChecked([ - "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], - "password" => [DefaultValidators::password()], - ], function (HttpRequest $req) { - $failures = []; - $account = $this->model->login($req["email"], $req["password"], $failures); - - if (!empty($failures)) { - return new JsonHttpResponse($failures, HttpCodes::UNAUTHORIZED); - } - - return new JsonHttpResponse(["authorization" => $account->getToken()]); - }); - } -} diff --git a/src/Api/Controller/APIServerController.php b/src/Api/Controller/APIServerController.php deleted file mode 100644 index 1c82d3e..0000000 --- a/src/Api/Controller/APIServerController.php +++ /dev/null @@ -1,45 +0,0 @@ -basePath = $basePath; - $this->pdo = $pdo; - } - - private function countLines(string $table): int { - $stmnt = $this->pdo->prepare("SELECT count(*) FROM $table"); - $stmnt->execute(); - $res = $stmnt->fetch(\PDO::FETCH_BOTH); - return $res[0]; - } - - - /** - * @return HttpResponse some (useless) information about the server - */ - public function getServerInfo(): HttpResponse { - - return new JsonHttpResponse([ - 'base_path' => $this->basePath, - 'date' => (int) gettimeofday(true) * 1000, - 'database' => [ - 'accounts' => $this->countLines("Account") . " line(s)", - 'tactics' => $this->countLines("Tactic") . " line(s)", - 'teams' => $this->countLines("Team") . " line(s)", - ], - ]); - } - -} diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php deleted file mode 100644 index eb00ff5..0000000 --- a/src/Api/Controller/APITacticController.php +++ /dev/null @@ -1,87 +0,0 @@ -model = $model; - } - - /** - * update name of tactic, specified by tactic identifier, given in url. - * @param int $tactic_id - * @param Account $account - * @return HttpResponse - */ - public function updateName(int $tactic_id, Account $account): HttpResponse { - return APIControl::runChecked([ - "name" => [DefaultValidators::lenBetween(1, 50), DefaultValidators::nameWithSpaces()], - ], function (HttpRequest $request) use ($tactic_id, $account) { - - $failures = $this->model->updateName($tactic_id, $request["name"], $account->getUser()->getId()); - - if (!empty($failures)) { - //TODO find a system to handle Unauthorized error codes more easily from failures. - return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST); - } - - return HttpResponse::fromCode(HttpCodes::OK); - }); - } - - /** - * @param int $id - * @param Account $account - * @return HttpResponse - */ - public function saveContent(int $id, Account $account): HttpResponse { - return APIControl::runChecked([ - "content" => [], - ], function (HttpRequest $req) use ($id) { - //TODO verify that the account has the rights to update the tactic content - if ($fail = $this->model->updateContent($id, json_encode($req["content"]))) { - return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); - } - return HttpResponse::fromCode(HttpCodes::OK); - }); - } - - - /** - * @param int $userId - * @return HttpResponse given user information. - */ - public function getUserTactics(int $userId): HttpResponse { - $tactics = $this->model->listAllOf($userId); - - $response = array_map(fn(TacticInfo $t) => [ - 'id' => $t->getId(), - 'name' => $t->getName(), - 'court' => $t->getCourtType(), - 'creation_date' => $t->getCreationDate(), - - ], $tactics); - - return new JsonHttpResponse($response); - } - - -} diff --git a/src/Api/Controller/APITeamController.php b/src/Api/Controller/APITeamController.php deleted file mode 100644 index 270468d..0000000 --- a/src/Api/Controller/APITeamController.php +++ /dev/null @@ -1,79 +0,0 @@ -teamModel = $teamModel; - } - - /** - * @param array $req_params - * @return HttpResponse - */ - public function listTeams(array $req_params): HttpResponse { - return APIControl::runCheckedFrom($req_params, [ - 'start' => [DefaultValidators::isUnsignedInteger()], - 'n' => [DefaultValidators::isUnsignedInteger()], - ], function (HttpRequest $req) { - $teams = $this->teamModel->listAll(intval($req['start']), intval($req['n'])); - return new JsonHttpResponse([ - "totalCount" => $this->teamModel->countTeam(), - "teams" => $teams, - ]); - }); - } - - public function addTeam(): HttpResponse { - return APIControl::runChecked([ - 'name' => [DefaultValidators::name()], - 'picture' => [DefaultValidators::isURL()], - 'mainColor' => [DefaultValidators::hexColor()], - 'secondaryColor' => [DefaultValidators::hexColor()], - - ], function (HttpRequest $req) { - $this->teamModel->createTeam($req['name'], $req['picture'], $req['mainColor'], $req['secondaryColor']); - return HttpResponse::fromCode(HttpCodes::OK); - }); - } - - public function deleteTeamSelected(): HttpResponse { - return APIControl::runChecked([ - 'teams' => [], - ], function (HttpRequest $req) { - $this->teamModel->deleteTeamSelected($req['teams']); - return HttpResponse::fromCode(HttpCodes::OK); - }); - } - - public function updateTeam(int $id): HttpResponse { - return APIControl::runChecked([ - 'name' => [DefaultValidators::name()], - 'picture' => [DefaultValidators::isURL()], - 'mainColor' => [DefaultValidators::hexColor()], - 'secondaryColor' => [DefaultValidators::hexColor()], - ], function (HttpRequest $req) { - $this->teamModel->editTeam($req['id'], $req['name'], $req['picture'], $req['mainColor'], $req['secondaryColor']); - return HttpResponse::fromCode(HttpCodes::OK); - }); - } - - -} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..843568d --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,37 @@ +import { + BrowserRouter, + createBrowserRouter, + createRoutesFromElements, + Route, + RouterProvider, + Routes +} from "react-router-dom"; + +import loadable from "@loadable/component"; + + +const HomePage = loadable(() => import("./pages/Home.tsx")) +const NotFoundPage = loadable(() => import("./pages/404.tsx")) +const CreateTeamPage = loadable(() => import("./pages/CreateTeamPage.tsx")) +const TeamPanelPage = loadable(() => import("./pages/TeamPanel.tsx")) +const NewTacticPage = loadable(() => import("./pages/NewTacticPage.tsx")) +const Editor = loadable(() => import("./pages/Editor.tsx")) + + +export default function App() { + return ( +
+ + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + +
+ ) +} \ No newline at end of file diff --git a/src/App/App.php b/src/App/App.php deleted file mode 100644 index 557ad13..0000000 --- a/src/App/App.php +++ /dev/null @@ -1,98 +0,0 @@ -getCode()); - - foreach ($response->getHeaders() as $header => $value) { - header("$header: $value"); - } - - if ($response instanceof ViewHttpResponse) { - self::renderView($response, $twigSupplier); - } elseif ($response instanceof JsonHttpResponse) { - header('Content-type: application/json'); - echo $response->getJson(); - } - } - - /** - * renders (prints out) given ViewHttpResponse to the client - * @param ViewHttpResponse $response - * @param callable(): Environment $twigSupplier - * @return void - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - */ - private static function renderView(ViewHttpResponse $response, callable $twigSupplier): void { - $file = $response->getFile(); - $args = $response->getArguments(); - - switch ($response->getViewKind()) { - case ViewHttpResponse::REACT_VIEW: - send_react_front($file, $args); - break; - case ViewHttpResponse::TWIG_VIEW: - try { - $twig = call_user_func($twigSupplier); - $twig->display($file, $args); - } catch (RuntimeError|SyntaxError|LoaderError $e) { - http_response_code(500); - echo "There was an error rendering your view, please refer to an administrator.\nlogs date: " . date("YYYD, d M Y H:i:s"); - throw $e; - } - break; - } - } - - /** - * run a user action, and return the generated response - * @param string $authRoute the route towards an authentication page to response with a redirection - * if the run action requires auth but session does not contain a logged-in account. - * @param Action $action - * @param mixed[] $params - * @param MutableSessionHandle $session - * @return HttpResponse - */ - public static function runAction(string $authRoute, Action $action, array $params, MutableSessionHandle $session): HttpResponse { - if ($action->getAuthType() != Action::NO_AUTH) { - $account = $session->getAccount(); - if ($account == null) { - // put in the session the initial url the user wanted to get - $session->setInitialTarget($_SERVER['REQUEST_URI']); - return HttpResponse::redirectAbsolute($authRoute); - } - - if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { - return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); - } - - } - - return $action->run($params, $session); - } - -} diff --git a/src/App/AppControl.php b/src/App/AppControl.php deleted file mode 100644 index c313e69..0000000 --- a/src/App/AppControl.php +++ /dev/null @@ -1,44 +0,0 @@ - $failures], HttpCodes::BAD_REQUEST); - } - }; - } - - /** - * Runs given callback, if the request's payload json validates the given schema. - * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * The callback must accept an HttpRequest, and return an HttpResponse object. - * @return HttpResponse - */ - public static function runChecked(array $schema, callable $run): HttpResponse { - return Control::runChecked($schema, $run, self::errorFactory()); - } - - /** - * Runs given callback, if the given request data array validates the given schema. - * @param array $data the request's data array. - * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * The callback must accept an HttpRequest, and return an HttpResponse object. - * @return HttpResponse - */ - public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { - return Control::runCheckedFrom($data, $schema, $run, self::errorFactory()); - } - -} diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php deleted file mode 100644 index 27b268a..0000000 --- a/src/App/Controller/AuthController.php +++ /dev/null @@ -1,99 +0,0 @@ -model = $model; - } - - public function displayRegister(): HttpResponse { - return ViewHttpResponse::twig("display_register.html.twig", []); - } - - /** - * registers given account - * @param mixed[] $requestData - * @param MutableSessionHandle $session - * @return HttpResponse - */ - public function register(array $requestData, MutableSessionHandle $session): HttpResponse { - $fails = []; - $request = HttpRequest::from($requestData, $fails, [ - "username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)], - "password" => [DefaultValidators::password()], - "confirmpassword" => [DefaultValidators::password()], - "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], - ]); - - if (!empty($fails)) { - return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails, 'username' => $requestData['username'], 'email' => $requestData['email'], 'password' => $requestData['password'], 'confirmpassword' => $requestData['confirmpassword'], 'accept' => true]); - } - - if ($request["password"] != $request['confirmpassword']) { - $fails[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes."); - } - - $account = $this->model->register($request['username'], $request["password"], $request['email']); - - if (!$account) { - $fails[] = new FieldValidationFail("email", "L'email existe déjà"); - } - - if (!empty($fails)) { - return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails, 'password' => $requestData['password'], 'confirmpassword' => $requestData['confirmpassword'], 'accept' => true]); - } - $session->setAccount($account); - - $target_url = $session->getInitialTarget(); - if ($target_url != null) { - return HttpResponse::redirectAbsolute($target_url); - } - - return HttpResponse::redirect("/home"); - } - - - public function displayLogin(): HttpResponse { - return ViewHttpResponse::twig("display_login.html.twig", []); - } - - /** - * logins given account credentials - * @param mixed[] $request - * @param MutableSessionHandle $session - * @return HttpResponse - */ - public function login(array $request, MutableSessionHandle $session): HttpResponse { - $fails = []; - $account = $this->model->login($request['email'], $request['password'], $fails); - if (!empty($fails)) { - return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails, 'password' => $request['password'], 'email' => $request['email']]); - } - - $session->setAccount($account); - - $target_url = $session->getInitialTarget(); - $session->setInitialTarget(null); - if ($target_url != null) { - return HttpResponse::redirectAbsolute($target_url); - } - - return HttpResponse::redirect("/home"); - } - -} diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php deleted file mode 100644 index ec21324..0000000 --- a/src/App/Controller/EditorController.php +++ /dev/null @@ -1,87 +0,0 @@ -model = $model; - } - - /** - * @param TacticInfo $tactic - * @return ViewHttpResponse the editor view for given tactic - */ - private function openEditorFor(TacticInfo $tactic): ViewHttpResponse { - return ViewHttpResponse::react("views/Editor.tsx", [ - "id" => $tactic->getId(), - "name" => $tactic->getName(), - "content" => $tactic->getContent(), - "courtType" => $tactic->getCourtType()->name(), - ]); - } - - public function createNew(): ViewHttpResponse { - return ViewHttpResponse::react("views/NewTacticPanel.tsx", []); - } - - /** - * @return ViewHttpResponse the editor view for a test tactic. - */ - private function openTestEditor(CourtType $courtType): ViewHttpResponse { - return ViewHttpResponse::react("views/Editor.tsx", [ - "id" => -1, //-1 id means that the editor will not support saves - "name" => TacticModel::TACTIC_DEFAULT_NAME, - "content" => '{"components": []}', - "courtType" => $courtType->name(), - ]); - } - - /** - * creates a new empty tactic, with default name - * If the given session does not contain a connected account, - * open a test editor. - * @param SessionHandle $session - * @param CourtType $type - * @return ViewHttpResponse the editor view - */ - public function createNewOfKind(CourtType $type, SessionHandle $session): ViewHttpResponse { - - $action = $session->getAccount(); - - if ($action == null) { - return $this->openTestEditor($type); - } - - $tactic = $this->model->makeNewDefault($session->getAccount()->getUser()->getId(), $type); - return $this->openEditorFor($tactic); - } - - /** - * returns an editor view for a given tactic - * @param int $id the targeted tactic identifier - * @param SessionHandle $session - * @return ViewHttpResponse - */ - public function openEditor(int $id, SessionHandle $session): ViewHttpResponse { - $tactic = $this->model->get($id); - - $failure = TacticValidator::validateAccess($id, $tactic, $session->getAccount()->getUser()->getId()); - - if ($failure != null) { - return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND); - } - - return $this->openEditorFor($tactic); - } -} diff --git a/src/App/Controller/TeamController.php b/src/App/Controller/TeamController.php deleted file mode 100644 index 048d182..0000000 --- a/src/App/Controller/TeamController.php +++ /dev/null @@ -1,246 +0,0 @@ -model = $model; - } - - /** - * @param SessionHandle $session - * @return ViewHttpResponse the team creation panel - */ - public function displayCreateTeam(SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("insert_team.html.twig", []); - } - - /** - * @param SessionHandle $session - * @return ViewHttpResponse the team panel to delete a member - */ - public function displayDeleteMember(SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("delete_member.html.twig", []); - } - - /** - * create a new team from given request name, mainColor, secondColor and picture url - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function submitTeam(array $request, SessionHandle $session): HttpResponse { - $failures = []; - $request = HttpRequest::from($request, $failures, [ - "name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()], - "main_color" => [DefaultValidators::hexColor()], - "second_color" => [DefaultValidators::hexColor()], - "picture" => [DefaultValidators::isURL()], - ]); - if (!empty($failures)) { - $badFields = []; - foreach ($failures as $e) { - if ($e instanceof FieldValidationFail) { - $badFields[] = $e->getFieldName(); - } - } - return ViewHttpResponse::twig('insert_team.html.twig', ['bad_fields' => $badFields]); - } - $teamId = $this->model->createTeam($request['name'], $request['picture'], $request['main_color'], $request['second_color']); - $this->model->addMember($session->getAccount()->getUser()->getEmail(), $teamId, 'COACH'); - return HttpResponse::redirect('/team/' . $teamId); - } - - /** - * @param SessionHandle $session - * @return ViewHttpResponse the panel to search a team by its name - */ - public function displayListTeamByName(SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("list_team_by_name.html.twig", []); - } - - /** - * returns a view that contains all the teams description whose name matches the given name needle. - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function listTeamByName(array $request, SessionHandle $session): HttpResponse { - $errors = []; - $request = HttpRequest::from($request, $errors, [ - "name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()], - ]); - - if (!empty($errors) && $errors[0] instanceof FieldValidationFail) { - $badField = $errors[0]->getFieldName(); - return ViewHttpResponse::twig('list_team_by_name.html.twig', ['bad_field' => $badField]); - } - - $teams = $this->model->listByName($request['name'], $session->getAccount()->getUser()->getId()); - - if (empty($teams)) { - return ViewHttpResponse::twig('display_teams.html.twig', []); - } - return ViewHttpResponse::twig('display_teams.html.twig', ['teams' => $teams]); - } - - /** - * Delete a team with its id - * @param int $id - * @param SessionHandle $session - * @return HttpResponse - */ - public function deleteTeamById(int $id, SessionHandle $session): HttpResponse { - $a = $session->getAccount(); - $ret = $this->model->deleteTeam($a->getUser()->getEmail(), $id); - if($ret != 0) { - return ViewHttpResponse::twig('display_team.html.twig', ['notDeleted' => true]); - } - return HttpResponse::redirect('/'); - } - - /** - * Display a team with its id - * @param int $id - * @param SessionHandle $session - * @return ViewHttpResponse a view that displays given team information - */ - public function displayTeam(int $id, SessionHandle $session): ViewHttpResponse { - $result = $this->model->getTeam($id, $session->getAccount()->getUser()->getId()); - if($result == null) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $role = $this->model->isCoach($id, $session->getAccount()->getUser()->getEmail()); - - return ViewHttpResponse::react( - 'views/TeamPanel.tsx', - [ - 'team' => [ - "info" => $result->getInfo(), - "members" => $result->listMembers(), - ], - 'isCoach' => $role, - 'currentUserId' => $session->getAccount()->getUser()->getId()] - ); - } - - /** - * @param int $idTeam - * @param SessionHandle $session - * @return ViewHttpResponse the team panel to add a member - */ - public function displayAddMember(int $idTeam, SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("add_member.html.twig", ['idTeam' => $idTeam]); - } - - /** - * add a member to a team - * @param int $idTeam - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function addMember(int $idTeam, array $request, SessionHandle $session): HttpResponse { - $errors = []; - if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $request = HttpRequest::from($request, $errors, [ - "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], - ]); - if(!empty($errors)) { - return ViewHttpResponse::twig('add_member.html.twig', ['badEmail' => true,'idTeam' => $idTeam]); - } - $ret = $this->model->addMember($request['email'], $idTeam, $request['role']); - - switch($ret) { - case -1: - return ViewHttpResponse::twig('add_member.html.twig', ['notFound' => true,'idTeam' => $idTeam]); - case -2: - return ViewHttpResponse::twig('add_member.html.twig', ['alreadyExisting' => true,'idTeam' => $idTeam]); - default: - return HttpResponse::redirect('/team/' . $idTeam); - } - } - - /** - * remove a member from a team with their ids - * @param int $idTeam - * @param int $idMember - * @param SessionHandle $session - * @return HttpResponse - */ - public function deleteMember(int $idTeam, int $idMember, SessionHandle $session): HttpResponse { - if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $teamId = $this->model->deleteMember($idMember, $idTeam); - if($teamId == -1 || $session->getAccount()->getUser()->getId() == $idMember) { - return HttpResponse::redirect('/'); - } - return $this->displayTeam($teamId, $session); - } - - /** - * @param int $idTeam - * @param SessionHandle $session - * @return ViewHttpResponse - */ - public function displayEditTeam(int $idTeam, SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::twig("edit_team.html.twig", ['team' => $this->model->getTeam($idTeam, $session->getAccount()->getUser()->getId())]); - } - - /** - * @param int $idTeam - * @param array $request - * @param SessionHandle $session - * @return HttpResponse - */ - public function editTeam(int $idTeam, array $request, SessionHandle $session): HttpResponse { - if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) { - return ViewHttpResponse::twig('error.html.twig', [ - 'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")], - ], HttpCodes::FORBIDDEN); - } - $failures = []; - $request = HttpRequest::from($request, $failures, [ - "name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()], - "main_color" => [DefaultValidators::hexColor()], - "second_color" => [DefaultValidators::hexColor()], - "picture" => [DefaultValidators::isURL()], - ]); - if (!empty($failures)) { - $badFields = []; - foreach ($failures as $e) { - if ($e instanceof FieldValidationFail) { - $badFields[] = $e->getFieldName(); - } - } - return ViewHttpResponse::twig('edit_team.html.twig', ['bad_fields' => $badFields]); - } - $this->model->editTeam($idTeam, $request['name'], $request['picture'], $request['main_color'], $request['second_color']); - return HttpResponse::redirect('/team/' . $idTeam); - } -} diff --git a/src/App/Controller/UserController.php b/src/App/Controller/UserController.php deleted file mode 100644 index 616bf54..0000000 --- a/src/App/Controller/UserController.php +++ /dev/null @@ -1,66 +0,0 @@ -tactics = $tactics; - $this->teams = $teams; - } - - /** - * @param SessionHandle $session - * @return ViewHttpResponse the home page view - */ - public function home(SessionHandle $session): ViewHttpResponse { - $limitNbTactics = 5; - - $user = $session->getAccount()->getUser(); - - $lastTactics = $this->tactics->getLast($limitNbTactics, $user->getId()); - $allTactics = $this->tactics->getAll($user->getId()); - $name = $user->getName(); - - if ($this->teams != null) { - $teams = $this->teams->getAll($user->getId()); - } else { - $teams = []; - } - - return ViewHttpResponse::react("views/Home.tsx", [ - "lastTactics" => $lastTactics, - "allTactics" => $allTactics, - "teams" => $teams, - "username" => $name, - ]); - } - - /** - * @return ViewHttpResponse account settings page - */ - public function settings(SessionHandle $session): ViewHttpResponse { - return ViewHttpResponse::react("views/Settings.tsx", []); - } - - public function disconnect(MutableSessionHandle $session): HttpResponse { - $session->destroy(); - return HttpResponse::redirect("/"); - } - -} diff --git a/src/App/Controller/VisualizerController.php b/src/App/Controller/VisualizerController.php deleted file mode 100644 index 946f6d0..0000000 --- a/src/App/Controller/VisualizerController.php +++ /dev/null @@ -1,39 +0,0 @@ -tacticModel = $tacticModel; - } - - /** - * Opens a visualisation page for the tactic specified by its identifier in the url. - * @param int $id - * @param SessionHandle $session - * @return HttpResponse - */ - public function openVisualizer(int $id, SessionHandle $session): HttpResponse { - $tactic = $this->tacticModel->get($id); - - $failure = TacticValidator::validateAccess($id, $tactic, $session->getAccount()->getUser()->getId()); - - if ($failure != null) { - return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND); - } - - return ViewHttpResponse::react("views/Visualizer.tsx", ["name" => $tactic->getName()]); - } -} diff --git a/src/App/Session/MutableSessionHandle.php b/src/App/Session/MutableSessionHandle.php deleted file mode 100644 index 14871b6..0000000 --- a/src/App/Session/MutableSessionHandle.php +++ /dev/null @@ -1,22 +0,0 @@ -getOwnerId() != $ownerId) { - return ValidationFail::unauthorized("Vous ne pouvez pas accéder à cette tactique."); - } - return null; - } - -} diff --git a/src/App/ViewHttpResponse.php b/src/App/ViewHttpResponse.php deleted file mode 100644 index dfbd1da..0000000 --- a/src/App/ViewHttpResponse.php +++ /dev/null @@ -1,75 +0,0 @@ - View arguments - */ - private array $arguments; - /** - * @var int Kind of view, see {@link self::TWIG_VIEW} and {@link self::REACT_VIEW} - */ - private int $kind; - - /** - * @param int $code - * @param int $kind - * @param string $file - * @param array $arguments - */ - private function __construct(int $kind, string $file, array $arguments, int $code = HttpCodes::OK) { - parent::__construct($code, []); - $this->kind = $kind; - $this->file = $file; - $this->arguments = $arguments; - } - - public function getViewKind(): int { - return $this->kind; - } - - public function getFile(): string { - return $this->file; - } - - /** - * @return array - */ - public function getArguments(): array { - return $this->arguments; - } - - /** - * Create a twig view response - * @param string $file - * @param array $arguments - * @param int $code - * @return ViewHttpResponse - */ - public static function twig(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { - return new ViewHttpResponse(self::TWIG_VIEW, $file, $arguments, $code); - } - - /** - * Create a react view response - * @param string $file - * @param array $arguments - * @param int $code - * @return ViewHttpResponse - */ - public static function react(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { - return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code); - } - -} diff --git a/src/App/Views/account_settings.twig b/src/App/Views/account_settings.twig deleted file mode 100644 index 04d7437..0000000 --- a/src/App/Views/account_settings.twig +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - Paramètres - - - - - - - -

Paramètres

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

IQBall

-
- -
-

Ajouter un membre à votre équipe

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

Email invalide

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

Cette personne n'a pas été trouvé

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

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

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

Supprimez un membre de votre équipe

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

Se connecter

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

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

S'enregistrer

-
-
- - {% for name in fails %} - - {% endfor %} - - - - - - - - -

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

Hello world

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

username: {{ v.name }}

-

description: {{ v.description }}

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

IQBall

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

Cette équipe ne peut être supprimée.

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

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

- -
-
-

Couleur principale :

-
-
-

Couleur secondaire :

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

{{ m.getUserId() }}

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

: Coach

- {% else %} -

: Joueur

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

Cette équipe ne peut être affichée

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

IQBall

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

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

-
-

Chercher une équipe

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

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

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

Modifier votre équipe

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

IQBall

- -{% for fail in failures %} -

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

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

IQ CourtObjects

-
- Account logo -

Mon profil -

-

-
- -

Mes équipes

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

{{ team.name }}

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

Aucune équipe créée !

-{% endif %} - -

Mes strategies

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

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

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

Aucune tactique créée !

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

Créer une équipe

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

IQBall

-
-
-

Chercher une équipe

-
-
- - -
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/App/react-display-file.php b/src/App/react-display-file.php deleted file mode 100755 index 2dfcd11..0000000 --- a/src/App/react-display-file.php +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - "> - - - - - Document - - - - - - - -
- - - - - - - - - \ No newline at end of file diff --git a/src/App/react-display.php b/src/App/react-display.php deleted file mode 100644 index 5baf41b..0000000 --- a/src/App/react-display.php +++ /dev/null @@ -1,13 +0,0 @@ - $arguments arguments to pass to the rendered react component - * The arguments must be a json-encodable key/value dictionary. - * @return void - */ -function send_react_front(string $url, array $arguments) { - // the $url and $argument values are used into the included file - require_once "react-display-file.php"; -} diff --git a/front/Constants.ts b/src/Constants.ts similarity index 100% rename from front/Constants.ts rename to src/Constants.ts diff --git a/src/Core/Action.php b/src/Core/Action.php deleted file mode 100644 index df40ea9..0000000 --- a/src/Core/Action.php +++ /dev/null @@ -1,70 +0,0 @@ -action = $action; - $this->authType = $authType; - } - - public function getAuthType(): int { - return $this->authType; - } - - /** - * Runs an action - * @param mixed[] $params - * @param S $session - * @return HttpResponse - */ - public function run(array $params, $session): HttpResponse { - $params = array_values($params); - $params[] = $session; - return call_user_func_array($this->action, $params); - } - - /** - * @param callable(mixed[], S): HttpResponse $action - * @return Action an action that does not require to have an authorization. - */ - public static function noAuth(callable $action): Action { - return new Action($action, self::NO_AUTH); - } - - /** - * @param callable(mixed[], S): HttpResponse $action - * @return Action an action that does require to have an authorization. - */ - public static function auth(callable $action): Action { - return new Action($action, self::AUTH_USER); - } - - /** - * @param callable(mixed[], S): HttpResponse $action - * @return Action an action that does require to have an authorization, and to be an administrator. - */ - public static function admin(callable $action): Action { - return new Action($action, self::AUTH_ADMIN); - } -} diff --git a/src/Core/Connection.php b/src/Core/Connection.php deleted file mode 100644 index 1dd559d..0000000 --- a/src/Core/Connection.php +++ /dev/null @@ -1,61 +0,0 @@ -pdo = $pdo; - } - - public function lastInsertId(): string { - return $this->pdo->lastInsertId(); - } - - /** - * execute a request - * @param string $query - * @param array> $args - * @return void - */ - public function exec(string $query, array $args) { - $stmnt = $this->prep($query, $args); - $stmnt->execute(); - } - - /** - * Execute a request, and return the returned rows - * @param string $query the SQL request - * @param array> $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]` - * @return array[] the returned rows of the request - */ - public function fetch(string $query, array $args): array { - $stmnt = $this->prep($query, $args); - $stmnt->execute(); - return $stmnt->fetchAll(PDO::FETCH_ASSOC); - } - - /** - * @param string $query - * @param array> $args - * @return \PDOStatement - */ - private function prep(string $query, array $args): \PDOStatement { - $stmnt = $this->pdo->prepare($query); - foreach ($args as $name => $value) { - $stmnt->bindValue($name, $value[0], $value[1]); - } - return $stmnt; - } - - public function prepare(string $query): \PDOStatement { - return $this->pdo->prepare($query); - } - -} diff --git a/src/Core/Control.php b/src/Core/Control.php deleted file mode 100644 index 51d6622..0000000 --- a/src/Core/Control.php +++ /dev/null @@ -1,53 +0,0 @@ - $schema an array of `fieldName => DefaultValidators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * The callback must accept an HttpRequest, and return an HttpResponse object. - * @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema - * @return HttpResponse - */ - public static function runChecked(array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): HttpResponse { - $request_body = file_get_contents('php://input'); - $payload_obj = json_decode($request_body); - if (!$payload_obj instanceof \stdClass) { - $fail = new ValidationFail("bad-payload", "request body is not a valid json object"); - return $errorFactory->apply([$fail]); - - } - $payload = get_object_vars($payload_obj); - return self::runCheckedFrom($payload, $schema, $run, $errorFactory); - } - - /** - * Runs given callback, if the given request data array validates the given schema. - * @param array $data the request's data array. - * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema - * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * The callback must accept an HttpRequest, and return an HttpResponse object. - * @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema - * @return HttpResponse - */ - public static function runCheckedFrom(array $data, array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): HttpResponse { - $fails = []; - $request = HttpRequest::from($data, $fails, $schema); - - if (!empty($fails)) { - return $errorFactory->apply($fails); - - } - - return call_user_func_array($run, [$request]); - } -} diff --git a/src/Core/ControlSchemaErrorResponseFactory.php b/src/Core/ControlSchemaErrorResponseFactory.php deleted file mode 100644 index 9882a65..0000000 --- a/src/Core/ControlSchemaErrorResponseFactory.php +++ /dev/null @@ -1,14 +0,0 @@ -token = $token; - $this->user = $user; - } - - public function getToken(): string { - return $this->token; - } - - /** - * @return User - */ - public function getUser(): User { - return $this->user; - } - -} diff --git a/src/Core/Data/CourtType.php b/src/Core/Data/CourtType.php deleted file mode 100755 index caad45c..0000000 --- a/src/Core/Data/CourtType.php +++ /dev/null @@ -1,61 +0,0 @@ - self::COURT_HALF) { - throw new InvalidArgumentException("Valeur du rôle invalide"); - } - $this->value = $val; - } - - public static function plain(): CourtType { - return new CourtType(CourtType::COURT_PLAIN); - } - - public static function half(): CourtType { - return new CourtType(CourtType::COURT_HALF); - } - - public function name(): string { - switch ($this->value) { - case self::COURT_HALF: - return "HALF"; - case self::COURT_PLAIN: - return "PLAIN"; - } - die("unreachable"); - } - - public static function fromName(string $name): ?CourtType { - switch ($name) { - case "HALF": - return CourtType::half(); - case "PLAIN": - return CourtType::plain(); - default: - return null; - } - } - - public function isPlain(): bool { - return ($this->value == self::COURT_PLAIN); - } - - public function isHalf(): bool { - return ($this->value == self::COURT_HALF); - } - -} diff --git a/src/Core/Data/Member.php b/src/Core/Data/Member.php deleted file mode 100755 index 30e4202..0000000 --- a/src/Core/Data/Member.php +++ /dev/null @@ -1,57 +0,0 @@ -user = $user; - $this->teamId = $teamId; - $this->role = $role; - } - - /** - * @return string - */ - public function getRole(): string { - return $this->role; - } - - /** - * @return int - */ - public function getTeamId(): int { - return $this->teamId; - } - - /** - * @return User - */ - public function getUser(): User { - return $this->user; - } - - - public function jsonSerialize() { - return get_object_vars($this); - } -} diff --git a/src/Core/Data/TacticInfo.php b/src/Core/Data/TacticInfo.php deleted file mode 100644 index c3b8667..0000000 --- a/src/Core/Data/TacticInfo.php +++ /dev/null @@ -1,62 +0,0 @@ -id = $id; - $this->name = $name; - $this->ownerId = $ownerId; - $this->creationDate = $creationDate; - $this->courtType = $type; - $this->content = $content; - } - - /** - * @return string - */ - public function getContent(): string { - return $this->content; - } - - public function getId(): int { - return $this->id; - } - - public function getName(): string { - return $this->name; - } - - /** - * @return int - */ - public function getOwnerId(): int { - return $this->ownerId; - } - - public function getCourtType(): CourtType { - return $this->courtType; - } - - /** - * @return int - */ - public function getCreationDate(): int { - return $this->creationDate; - } -} diff --git a/src/Core/Data/Team.php b/src/Core/Data/Team.php deleted file mode 100755 index 7adeb49..0000000 --- a/src/Core/Data/Team.php +++ /dev/null @@ -1,38 +0,0 @@ -info = $info; - $this->members = $members; - } - - public function getInfo(): TeamInfo { - return $this->info; - } - - /** - * @return Member[] - */ - public function listMembers(): array { - return $this->members; - } - - public function jsonSerialize() { - return get_object_vars($this); - } - - -} diff --git a/src/Core/Data/TeamInfo.php b/src/Core/Data/TeamInfo.php deleted file mode 100644 index 964990c..0000000 --- a/src/Core/Data/TeamInfo.php +++ /dev/null @@ -1,50 +0,0 @@ -id = $id; - $this->name = $name; - $this->picture = $picture; - $this->mainColor = $mainColor; - $this->secondColor = $secondColor; - } - - public function getId(): int { - return $this->id; - } - - public function getName(): string { - return $this->name; - } - - public function getPicture(): string { - return $this->picture; - } - - public function getMainColor(): string { - return $this->mainColor; - } - - public function getSecondColor(): string { - return $this->secondColor; - } - - public function jsonSerialize() { - return get_object_vars($this); - } -} diff --git a/src/Core/Data/User.php b/src/Core/Data/User.php deleted file mode 100644 index 8471929..0000000 --- a/src/Core/Data/User.php +++ /dev/null @@ -1,84 +0,0 @@ -email = $email; - $this->name = $name; - $this->id = $id; - $this->profilePicture = $profilePicture; - $this->isAdmin = $isAdmin; - } - - /** - * @return bool - */ - public function isAdmin(): bool { - return $this->isAdmin; - } - - /** - * @return string - */ - public function getEmail(): string { - return $this->email; - } - - /** - * @return string - */ - public function getName(): string { - return $this->name; - } - - /** - * @return int - */ - public function getId(): int { - return $this->id; - } - - /** - * @return string - */ - public function getProfilePicture(): string { - return $this->profilePicture; - } - - public function jsonSerialize() { - return get_object_vars($this); - } -} diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php deleted file mode 100644 index 1a0c689..0000000 --- a/src/Core/Gateway/AccountGateway.php +++ /dev/null @@ -1,180 +0,0 @@ -con = $con; - } - - public function insertAccount(string $name, string $email, string $token, string $hash, string $profilePicture): int { - $this->con->exec("INSERT INTO Account(username, hash, email, token,profile_picture) VALUES (:username,:hash,:email,:token,:profile_pic)", [ - ':username' => [$name, PDO::PARAM_STR], - ':hash' => [$hash, PDO::PARAM_STR], - ':email' => [$email, PDO::PARAM_STR], - ':token' => [$token, PDO::PARAM_STR], - ':profile_pic' => [$profilePicture, PDO::PARAM_STR], - ]); - return intval($this->con->lastInsertId()); - } - - public function updateAccount(int $id, string $name, string $email, string $token, bool $isAdmin): void { - $this->con->exec("UPDATE Account SET username = :username, email = :email, token = :token WHERE id = :id", [ - ':username' => [$name, PDO::PARAM_STR], - ':email' => [$email, PDO::PARAM_STR], - ':token' => [$token, PDO::PARAM_STR], - ':id' => [$id, PDO::PARAM_INT], - ]); - $this->setIsAdmin($id, $isAdmin); - } - - public function isAdmin(int $id): bool { - $stmnt = $this->con->prepare("SELECT * FROM Admins WHERE id = :id"); - $stmnt->bindValue(':id', $id, PDO::PARAM_INT); - $stmnt->execute(); - $result = $stmnt->fetchAll(PDO::FETCH_ASSOC); - - return !empty($result); - } - - /** - * promote or demote a user to server administrator - * @param int $id - * @param bool $isAdmin true to promote, false to demote - * @return bool true if the given user exists - */ - public function setIsAdmin(int $id, bool $isAdmin): bool { - if ($isAdmin) { - $stmnt = $this->con->prepare("INSERT INTO Admins VALUES(:id)"); - } else { - $stmnt = $this->con->prepare("DELETE FROM Admins WHERE id = :id"); - } - - $stmnt->bindValue(':id', $id); - $stmnt->execute(); - - return $stmnt->rowCount() > 0; - } - - /** - * @param string $email - * @return array|null - */ - private function getRowsFromMail(string $email): ?array { - return $this->con->fetch("SELECT * FROM Account WHERE email = :email", [':email' => [$email, PDO::PARAM_STR]])[0] ?? null; - } - - /** - * @param string $email - * @return string|null the hashed user's password, or null if the given mail does not exist - */ - public function getHash(string $email): ?string { - $results = $this->getRowsFromMail($email); - if ($results == null) { - return null; - } - return $results['hash']; - } - - /** - * @param string $email - * @return bool true if the given email exists in the database - */ - public function exists(string $email): bool { - return $this->getRowsFromMail($email) != null; - } - - /** - * @param string $email - * @return Account|null - */ - public function getAccountFromMail(string $email): ?Account { - $acc = $this->getRowsFromMail($email); - if (empty($acc)) { - return null; - } - - return new Account($acc["token"], new User($email, $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))); - } - - /** - * @param string $token get an account from given token - * @return Account|null - */ - public function getAccountFromToken(string $token): ?Account { - $stmnt = $this->con->prepare("SELECT * FROM Account WHERE token = :token"); - $stmnt->bindValue(':token', $token); - return $this->getAccountFrom($stmnt); - } - - /** - * @param int $id get an account from given identifier - * @return Account|null - */ - public function getAccount(int $id): ?Account { - $stmnt = $this->con->prepare("SELECT * FROM Account WHERE id = :id"); - $stmnt->bindValue(':id', $id); - return $this->getAccountFrom($stmnt); - } - - private function getAccountFrom(\PDOStatement $stmnt): ?Account { - $stmnt->execute(); - $acc = $stmnt->fetch(PDO::FETCH_ASSOC); - - if ($acc == null) { - return null; - } - - return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))); - } - - /** - * Return a list containing n accounts from a given starting index - * - * @param integer $n the number of accounts to retrieve - * @param int $start starting index of the list content - * @return Account[] - */ - public function searchAccounts(int $start, int $n, ?string $searchString): array { - $res = $this->con->fetch( - "SELECT * FROM Account WHERE username LIKE '%' || :search || '%' OR email LIKE '%' || :search || '%' ORDER BY username, email LIMIT :offset, :n", - [ - ":offset" => [$start, PDO::PARAM_INT], - ":n" => [$n, PDO::PARAM_INT], - ":search" => [$searchString ?? "", PDO::PARAM_STR], - ] - ); - return array_map(fn(array $acc) => new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))), $res); - - } - - /** - * returns the total amount of accounts in the database - * @return int - */ - public function totalCount(): int { - return $this->con->fetch("SELECT count(*) FROM Account", [])[0]['count(*)']; - } - - /** - * remove a bunch of account identifiers - * @param int[] $accountIds - */ - public function removeAccounts(array $accountIds): void { - foreach ($accountIds as $accountId) { - $this->con->fetch("DELETE FROM Account WHERE id = :accountId", [ - ":accountId" => [$accountId, PDO::PARAM_INT], - ]); - } - } -} diff --git a/src/Core/Gateway/MemberGateway.php b/src/Core/Gateway/MemberGateway.php deleted file mode 100644 index f79ff60..0000000 --- a/src/Core/Gateway/MemberGateway.php +++ /dev/null @@ -1,101 +0,0 @@ -con = $con; - } - - /** - * insert member to a team - * @param int $idTeam - * @param int $userId - * @param string $role - * @return void - */ - public function insert(int $idTeam, int $userId, string $role): void { - $this->con->exec( - "INSERT INTO Member(id_team, id_user, role) VALUES (:id_team, :id_user, :role)", - [ - ":id_team" => [$idTeam, PDO::PARAM_INT], - ":id_user" => [$userId, PDO::PARAM_INT], - ":role" => [$role, PDO::PARAM_STR], - ] - ); - } - - /** - * @param int $teamId - * @return Member[] - */ - public function getMembersOfTeam(int $teamId): array { - $rows = $this->con->fetch( - "SELECT a.id,a.email,a.username,a.profile_picture,m.role FROM Account a,team t,Member m WHERE t.id = :id AND m.id_team = t.id AND m.id_user = a.id", - [ - ":id" => [$teamId, PDO::PARAM_INT], - ] - ); - return array_map(fn($row) => new Member(new User($row['email'], $row['username'], $row['id'], $row['profile_picture'], $row['is_admin']), $teamId, $row['role']), $rows); - - } - - /** - * remove member from given team - * @param int $idTeam - * @param int $idMember - * @return void - */ - public function remove(int $idTeam, int $idMember): void { - $this->con->exec( - "DELETE FROM Member WHERE id_team = :id_team AND id_user = :id_user", - [ - ":id_team" => [$idTeam, PDO::PARAM_INT], - ":id_user" => [$idMember, PDO::PARAM_INT], - ] - ); - } - - /** - * @param string $email - * @param int $idTeam - * @return bool - */ - public function isCoach(string $email, int $idTeam): bool { - $result = $this->con->fetch( - "SELECT role FROM Member WHERE id_team=:team AND id_user = (SELECT id FROM Account WHERE email=:email)", - [ - "team" => [$idTeam, PDO::PARAM_INT], - "email" => [$email, PDO::PARAM_STR], - ] - )[0]['role']; - - return $result == 'COACH'; - } - - /** - * @param int $idTeam - * @param int $idCurrentUser - * @return bool - */ - public function isMemberOfTeam(int $idTeam, int $idCurrentUser): bool { - $result = $this->con->fetch( - "SELECT id_user FROM Member WHERE id_team = :team AND id_user = :user", - [ - "team" => [$idTeam, PDO::PARAM_INT], - "user" => [$idCurrentUser, PDO::PARAM_INT], - ] - ); - return !empty($result); - } -} diff --git a/src/Core/Gateway/TacticInfoGateway.php b/src/Core/Gateway/TacticInfoGateway.php deleted file mode 100644 index d4b81e0..0000000 --- a/src/Core/Gateway/TacticInfoGateway.php +++ /dev/null @@ -1,151 +0,0 @@ -con = $con; - } - - /** - * get tactic information from given identifier - * @param int $id - * @return TacticInfo|null - */ - public function get(int $id): ?TacticInfo { - $res = $this->con->fetch( - "SELECT * FROM Tactic WHERE id = :id", - [":id" => [$id, PDO::PARAM_INT]] - ); - - if (!isset($res[0])) { - return null; - } - - $row = $res[0]; - - $type = CourtType::fromName($row['court_type']); - return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"], $type, $row['content']); - } - - - /** - * Return the nb last tactics created - * - * @param integer $nb - * @return array> - */ - public function getLast(int $nb, int $ownerId): ?array { - $res = $this->con->fetch( - "SELECT * - FROM Tactic - WHERE owner = :ownerId - ORDER BY creation_date DESC - LIMIT :nb", - [ - ":ownerId" => [$ownerId, PDO::PARAM_INT],":nb" => [$nb, PDO::PARAM_INT], - ] - ); - if (count($res) == 0) { - return []; - } - return $res; - } - - /** - * Get all the tactics of the owner - * - * @return array> - */ - public function getAll(int $ownerId): ?array { - $res = $this->con->fetch( - "SELECT * - FROM Tactic - WHERE owner = :ownerId - ORDER BY name DESC", - [ - ":ownerId" => [$ownerId, PDO::PARAM_INT], - ] - ); - if (count($res) == 0) { - return []; - } - return $res; - } - - - /** - * Return a list containing the nth last tactics of a given user id - * - * @param integer $user_id - * @return TacticInfo[] - */ - public function listAllOf(int $user_id): array { - $res = $this->con->fetch( - "SELECT * FROM Tactic WHERE owner = :owner_id ORDER BY creation_date DESC", - [ - ":owner_id" => [$user_id, PDO::PARAM_STR], - ] - ); - return array_map(fn(array $t) => new TacticInfo($t['id'], $t["name"], strtotime($t["creation_date"]), $t["owner"], CourtType::fromName($t['court_type']), $t['content']), $res); - } - - - /** - * @param string $name - * @param int $owner - * @param CourtType $type - * @return int inserted tactic id - */ - public function insert(string $name, int $owner, CourtType $type): int { - $this->con->exec( - "INSERT INTO Tactic(name, owner, court_type) VALUES(:name, :owner, :court_type)", - [ - ":name" => [$name, PDO::PARAM_STR], - ":owner" => [$owner, PDO::PARAM_INT], - ":court_type" => [$type->name(), PDO::PARAM_STR], - ] - ); - return intval($this->con->lastInsertId()); - } - - /** - * update name of given tactic identifier - * @param int $id - * @param string $name - * @return bool - */ - public function updateName(int $id, string $name): bool { - $stmnt = $this->con->prepare("UPDATE Tactic SET name = :name WHERE id = :id"); - $stmnt->execute([ - ":name" => $name, - ":id" => $id, - ]); - return $stmnt->rowCount() == 1; - } - - /*** - * Updates a given tactics content - * @param int $id - * @param string $json - * @return bool - */ - public function updateContent(int $id, string $json): bool { - $stmnt = $this->con->prepare("UPDATE Tactic SET content = :content WHERE id = :id"); - $stmnt->execute([ - ":content" => $json, - ":id" => $id, - ]); - return $stmnt->rowCount() == 1; - } -} diff --git a/src/Core/Gateway/TeamGateway.php b/src/Core/Gateway/TeamGateway.php deleted file mode 100644 index 4309a49..0000000 --- a/src/Core/Gateway/TeamGateway.php +++ /dev/null @@ -1,186 +0,0 @@ -con = $con; - } - - /** - * @param string $name - * @param string $picture - * @param string $mainColor - * @param string $secondColor - * @return int the inserted team identifier - */ - public function insert(string $name, string $picture, string $mainColor, string $secondColor): int { - $this->con->exec( - "INSERT INTO team(name, picture, main_color, second_color) VALUES (:team_name , :picture, :main_color, :second_color)", - [ - ":team_name" => [$name, PDO::PARAM_STR], - ":picture" => [$picture, PDO::PARAM_STR], - ":main_color" => [$mainColor, PDO::PARAM_STR], - ":second_color" => [$secondColor, PDO::PARAM_STR], - ] - ); - return intval($this->con->lastInsertId()); - } - - /** - * @param string $name - * @param int $id - * @return TeamInfo[] - */ - public function listByName(string $name, int $id): array { - $result = $this->con->fetch( - "SELECT t.* FROM team t, Member m WHERE t.name LIKE '%' || :name || '%' AND t.id=m.id_team AND m.id_user=:id", - [ - ":name" => [$name, PDO::PARAM_STR], - "id" => [$id, PDO::PARAM_INT], - ] - ); - - return array_map(fn($row) => new TeamInfo($row['id'], $row['name'], $row['picture'], $row['main_color'], $row['second_color']), $result); - } - - /** - * @param int $id - * @return TeamInfo|null - */ - public function getTeamById(int $id): ?TeamInfo { - $row = $this->con->fetch( - "SELECT * FROM team WHERE id = :id", - [ - ":id" => [$id, PDO::PARAM_INT], - ] - )[0] ?? null; - if ($row == null) { - return null; - } - return new TeamInfo($row['id'], $row['name'], $row['picture'], $row['main_color'], $row['second_color']); - } - - /** - * @param string $name - * @return int|null - */ - public function getTeamIdByName(string $name): ?int { - return $this->con->fetch( - "SELECT id FROM team WHERE name = :name", - [ - ":name" => [$name, PDO::PARAM_INT], - ] - )[0]['id'] ?? null; - } - - /** - * @param int $idTeam - */ - public function deleteTeam(int $idTeam): void { - $this->con->exec( - "DELETE FROM Member WHERE id_team=:team", - [ - "team" => [$idTeam, PDO::PARAM_INT], - ] - ); - $this->con->exec( - "DELETE FROM TEAM WHERE id=:team", - [ - "team" => [$idTeam, PDO::PARAM_INT], - ] - ); - } - - /** - * @param int $idTeam - * @param string $newName - * @param string $newPicture - * @param string $newMainColor - * @param string $newSecondColor - * @return void - */ - public function editTeam(int $idTeam, string $newName, string $newPicture, string $newMainColor, string $newSecondColor) { - $this->con->exec( - "UPDATE team - SET name = :newName, - picture = :newPicture, - main_color = :newMainColor, - second_color = :newSecondColor - WHERE id = :team", - [ - "team" => [$idTeam, PDO::PARAM_INT], - "newName" => [$newName, PDO::PARAM_STR], - "newPicture" => [$newPicture, PDO::PARAM_STR], - "newMainColor" => [$newMainColor, PDO::PARAM_STR], - "newSecondColor" => [$newSecondColor, PDO::PARAM_STR], - ] - ); - - } - - /** - * @param int $user - * @return array - */ - public function getAll(int $user): array { - return $this->con->fetch( - "SELECT t.* FROM team t,Member m WHERE m.id_team = t.id AND m.id_user= :idUser ", - [ - "idUser" => [$user, PDO::PARAM_INT], - ] - ); - } - - /** - * @param int $start - * @param int $n - * @return TeamInfo[] - */ - public function listAll(int $start, int $n): array { - $rows = $this->con->fetch( - "SELECT * FROM Team LIMIT :start, :n", - [ - ":start" => [$start, PDO::PARAM_INT], - ":n" => [$n, PDO::PARAM_INT], - ] - ); - return array_map(fn($row) => new TeamInfo($row['id'], $row['name'], $row['picture'], $row['main_color'], $row['second_color']), $rows); - } - - public function countTeam(): int { - $result = $this->con->fetch( - "SELECT count(*) as count FROM Team", - [] - ); - if (empty($result) || !isset($result[0]['count'])) { - return 0; - } - return $result[0]['count']; - } - - /** - * @param array $selectedTeams - * @return void - */ - public function deleteTeamSelected(array $selectedTeams): void { - foreach ($selectedTeams as $team) { - $this->con->exec( - "DELETE FROM TEAM WHERE id=:team", - [ - "team" => [$team, PDO::PARAM_INT], - ] - ); - } - } - - -} diff --git a/src/Core/Http/HttpCodes.php b/src/Core/Http/HttpCodes.php deleted file mode 100644 index 1903f0c..0000000 --- a/src/Core/Http/HttpCodes.php +++ /dev/null @@ -1,18 +0,0 @@ - - * */ -class HttpRequest implements ArrayAccess { - /** - * @var array - */ - private array $data; - - /** - * @param array $data - */ - private function __construct(array $data) { - $this->data = $data; - } - - /** - * Creates a new HttpRequest instance, and ensures that the given request data validates the given schema. - * This is a simple function that only supports flat schemas (non-composed, the data must only be a k/v array pair.) - * @param array $request the request's data - * @param array $fails a reference to a failure array, that will contain the reported validation failures. - * @param array $schema the schema to satisfy. a schema is a simple array with a string key (which is the top-level field name), and a set of validators - * @return HttpRequest|null the built HttpRequest instance, or null if a field is missing, or if any of the schema validator failed - */ - public static function from(array $request, array &$fails, array $schema): ?HttpRequest { - $failure = false; - foreach ($schema as $fieldName => $fieldValidators) { - if (!isset($request[$fieldName])) { - $fails[] = FieldValidationFail::missing($fieldName); - $failure = true; - continue; - } - $failure |= Validation::validate($request[$fieldName], $fieldName, $fails, ...$fieldValidators); - } - - if ($failure) { - return null; - } - return new HttpRequest($request); - } - - public function offsetExists($offset): bool { - return isset($this->data[$offset]); - } - - /** - * @param $offset - * @return mixed - */ - public function offsetGet($offset) { - return $this->data[$offset]; - } - - /** - * @param $offset - * @param $value - * @throws Exception - */ - public function offsetSet($offset, $value) { - throw new Exception("requests are immutable objects."); - } - - /** - * @param $offset - * @throws Exception - */ - public function offsetUnset($offset) { - throw new Exception("requests are immutable objects."); - } -} diff --git a/src/Core/Http/HttpResponse.php b/src/Core/Http/HttpResponse.php deleted file mode 100644 index 6c6a743..0000000 --- a/src/Core/Http/HttpResponse.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ - private array $headers; - private int $code; - - /** - * @param int $code - * @param array $headers - */ - public function __construct(int $code, array $headers) { - $this->code = $code; - $this->headers = $headers; - } - - public function getCode(): int { - return $this->code; - } - - /** - * @return array - */ - public function getHeaders(): array { - return $this->headers; - } - - /** - * @param int $code - * @return HttpResponse - */ - public static function fromCode(int $code): HttpResponse { - return new HttpResponse($code, []); - } - - /** - * @param string $url the url to redirect - * @param int $code only HTTP 3XX codes are accepted. - * @return HttpResponse a response that will redirect client to given url - */ - - public static function redirect(string $url, int $code = HttpCodes::FOUND): HttpResponse { - global $basePath; - return self::redirectAbsolute($basePath . $url, $code); - } - - /** - * @param string $url the url to redirect - * @param int $code only HTTP 3XX codes are accepted. - * @return HttpResponse a response that will redirect client to given url - */ - - public static function redirectAbsolute(string $url, int $code = HttpCodes::FOUND): HttpResponse { - if ($code < 300 || $code >= 400) { - throw new \InvalidArgumentException("given code is not a redirection http code"); - } - return new HttpResponse($code, ["Location" => $url]); - } - - -} diff --git a/src/Core/Http/JsonHttpResponse.php b/src/Core/Http/JsonHttpResponse.php deleted file mode 100644 index bb897f7..0000000 --- a/src/Core/Http/JsonHttpResponse.php +++ /dev/null @@ -1,28 +0,0 @@ -payload = $payload; - } - - public function getJson(): string { - $result = json_encode($this->payload); - if (!$result) { - throw new \RuntimeException("Given payload is not json encodable"); - } - - return $result; - } - -} diff --git a/src/Core/Model/AuthModel.php b/src/Core/Model/AuthModel.php deleted file mode 100644 index e1fc1bb..0000000 --- a/src/Core/Model/AuthModel.php +++ /dev/null @@ -1,78 +0,0 @@ -gateway = $gateway; - } - - /** - * @param string $username - * @param string $password - * @param string $email - * @return Account|null the registered account or null if the account already exists for the given email address - */ - public function register( - string $username, - string $password, - string $email - ): ?Account { - if ($this->gateway->exists($email)) { - return null; - } - - $hash = password_hash($password, PASSWORD_DEFAULT); - $token = $this->generateToken(); - $accountId = $this->gateway->insertAccount($username, $email, $token, $hash, self::DEFAULT_PROFILE_PICTURE); - return new Account($token, new User($email, $username, $accountId, self::DEFAULT_PROFILE_PICTURE, false)); - } - - /** - * Generate a random base 64 string - * @return string - */ - public static function generateToken(): string { - try { - return base64_encode(random_bytes(64)); - } catch (Exception $e) { - throw new \RuntimeException($e); - } - } - - /** - * @param string $email - * @param string $password - * @param ValidationFail[] $failures - * @return Account|null the authenticated account or null if failures occurred - */ - public function login(string $email, string $password, array &$failures): ?Account { - $hash = $this->gateway->getHash($email); - if ($hash == null or (!password_verify($password, $hash))) { - $failures[] = new ValidationFail("email", "Adresse email ou mot de passe invalide"); - return null; - } - return $this->gateway->getAccountFromMail($email); - } - - public function update(int $id, string $email, string $username, bool $isAdmin): void { - $token = $this->generateToken(); - $this->gateway->updateAccount($id, $username, $email, $token, $isAdmin); - } - -} diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php deleted file mode 100644 index 920075b..0000000 --- a/src/Core/Model/TacticModel.php +++ /dev/null @@ -1,117 +0,0 @@ -tactics = $tactics; - } - - /** - * creates a new empty tactic, with given name - * @param string $name - * @param int $ownerId - * @param CourtType $type - * @return TacticInfo - */ - public function makeNew(string $name, int $ownerId, CourtType $type): TacticInfo { - $id = $this->tactics->insert($name, $ownerId, $type); - return $this->tactics->get($id); - } - - /** - * creates a new empty tactic, with a default name - * @param int $ownerId - * @param CourtType $type - * @return TacticInfo|null - */ - public function makeNewDefault(int $ownerId, CourtType $type): ?TacticInfo { - return $this->makeNew(self::TACTIC_DEFAULT_NAME, $ownerId, $type); - } - - /** - * Tries to retrieve information about a tactic - * @param int $id tactic identifier - * @return TacticInfo|null or null if the identifier did not match a tactic - */ - public function get(int $id): ?TacticInfo { - return $this->tactics->get($id); - } - - /** - * Return the nb last tactics - * - * @param integer $nb - * @param integer $ownerId - * @return array> - */ - public function getLast(int $nb, int $ownerId): array { - return $this->tactics->getLast($nb, $ownerId); - } - - - /** - * Return a list containing all the tactics of a given user - * NOTE: if given user id does not match any user, this function returns an empty array - * - * @param integer $user_id - * @return TacticInfo[] - */ - public function listAllOf(int $user_id): array { - return$this->tactics->listAllOf($user_id); - } - - /** - * Get all the tactics of the owner - * - * @param integer $ownerId - * @return array> - */ - public function getAll(int $ownerId): ?array { - return $this->tactics->getAll($ownerId); - } - /** - * Update the name of a tactic - * @param int $id the tactic identifier - * @param string $name the new name to set - * @return ValidationFail[] failures, if any - */ - public function updateName(int $id, string $name, int $authId): array { - - $tactic = $this->tactics->get($id); - - if ($tactic == null) { - return [ValidationFail::notFound("Could not find tactic")]; - } - - if ($tactic->getOwnerId() != $authId) { - return [ValidationFail::unauthorized()]; - } - - if (!$this->tactics->updateName($id, $name)) { - return [ValidationFail::error("Could not update name")]; - } - return []; - } - - public function updateContent(int $id, string $json): ?ValidationFail { - if (!$this->tactics->updateContent($id, $json)) { - return ValidationFail::error("Could not update content"); - } - return null; - } - -} diff --git a/src/Core/Model/TeamModel.php b/src/Core/Model/TeamModel.php deleted file mode 100644 index b6b7bdd..0000000 --- a/src/Core/Model/TeamModel.php +++ /dev/null @@ -1,164 +0,0 @@ -teams = $gateway; - $this->members = $members; - $this->users = $users; - } - - /** - * Create a team - * @param string $name - * @param string $picture - * @param string $mainColor - * @param string $secondColor - * @return int - */ - public function createTeam(string $name, string $picture, string $mainColor, string $secondColor): int { - return $this->teams->insert($name, $picture, $mainColor, $secondColor); - } - - /** - * add a member to a team - * @param string $mail - * @param int $teamId - * @param string $role - * @return int - */ - public function addMember(string $mail, int $teamId, string $role): int { - $user = $this->users->getAccountFromMail($mail); - if ($user == null) { - return -1; - } - if (!$this->members->isMemberOfTeam($teamId, $user->getUser()->getId())) { - $this->members->insert($teamId, $user->getUser()->getId(), $role); - return 1; - } - return -2; - } - - /** - * @param string $name - * @param int $id - * @return TeamInfo[] - */ - public function listByName(string $name, int $id): array { - return $this->teams->listByName($name, $id); - } - - /** - * @param int $idTeam - * @param int $idCurrentUser - * @return Team|null - */ - public function getTeam(int $idTeam, int $idCurrentUser): ?Team { - if (!$this->members->isMemberOfTeam($idTeam, $idCurrentUser)) { - return null; - } - $teamInfo = $this->teams->getTeamById($idTeam); - $members = $this->members->getMembersOfTeam($idTeam); - return new Team($teamInfo, $members); - } - - /** - * delete a member from given team identifier - * @param int $idMember - * @param int $teamId - * @return int - */ - public function deleteMember(int $idMember, int $teamId): int { - $this->members->remove($teamId, $idMember); - if (empty($this->members->getMembersOfTeam($teamId))) { - $this->teams->deleteTeam($teamId); - return -1; - } - return $teamId; - } - - /** - * Delete a team - * @param string $email - * @param int $idTeam - * @return int - */ - public function deleteTeam(string $email, int $idTeam): int { - if ($this->members->isCoach($email, $idTeam)) { - $this->teams->deleteTeam($idTeam); - return 0; - } - return -1; - } - - /** - * Verify if the account associated to an email is in a specific team indicated with its id - * @param int $idTeam - * @param string $email - * @return bool - */ - public function isCoach(int $idTeam, string $email): bool { - return $this->members->isCoach($email, $idTeam); - } - - /** - * Edit a team with its id, and replace the current attributes with the new ones - * @param int $idTeam - * @param string $newName - * @param string $newPicture - * @param string $newMainColor - * @param string $newSecondColor - * @return void - */ - public function editTeam(int $idTeam, string $newName, string $newPicture, string $newMainColor, string $newSecondColor) { - $this->teams->editTeam($idTeam, $newName, $newPicture, $newMainColor, $newSecondColor); - } - - /** - * Get all user's teams - * - * @param integer $user - * @return array> - */ - public function getAll(int $user): array { - return $this->teams->getAll($user); - } - - /** - * @param int $start - * @param int $n - * @return TeamInfo[] - */ - public function listAll(int $start, int $n) { - return $this->teams->listAll($start, $n); - } - - public function countTeam(): int { - return $this->teams->countTeam(); - } - - /** - * @param array $selectedTeams - * @return void - */ - public function deleteTeamSelected(array $selectedTeams) { - $this->teams->deleteTeamSelected($selectedTeams); - } - -} diff --git a/src/Core/Validation/ComposedValidator.php b/src/Core/Validation/ComposedValidator.php deleted file mode 100644 index 58f4910..0000000 --- a/src/Core/Validation/ComposedValidator.php +++ /dev/null @@ -1,26 +0,0 @@ -first = $first; - $this->then = $then; - } - - public function validate(string $name, $val): array { - $firstFailures = $this->first->validate($name, $val); - $thenFailures = []; - if (empty($firstFailures)) { - $thenFailures = $this->then->validate($name, $val); - } - return array_merge($firstFailures, $thenFailures); - } -} diff --git a/src/Core/Validation/DefaultValidators.php b/src/Core/Validation/DefaultValidators.php deleted file mode 100644 index c898170..0000000 --- a/src/Core/Validation/DefaultValidators.php +++ /dev/null @@ -1,146 +0,0 @@ - preg_match($regex, $str), - fn(string $name) => [new FieldValidationFail($name, $msg == null ? "le champ ne valide pas le pattern $regex" : $msg)] - ); - } - - public static function hex(?string $msg = null): Validator { - return self::regex('/#([0-9a-fA-F])/', $msg == null ? "le champ n'est pas un nombre hexadecimal valide" : $msg); - } - - public static function hexColor(?string $msg = null): Validator { - return self::regex('/#([0-9a-fA-F]{6})/', $msg == null ? "le champ n'est pas une couleur valide" : $msg); - } - - /** - * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-` and `_`. - */ - public static function name(?string $msg = null): Validator { - return self::regex("/^[0-9a-zA-Zà-üÀ-Ü_-]*$/", $msg); - } - - /** - * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-`, `_` and spaces. - */ - public static function nameWithSpaces(): Validator { - return self::regex("/^[0-9a-zA-Zà-üÀ-Ü _-]*$/"); - } - - public static function password(): Validator { - return self::lenBetween(6, 256); - } - - /** - * Validate string if its length is between given range - * @param int $min minimum accepted length, inclusive - * @param int $max maximum accepted length, exclusive - * @return Validator - */ - public static function lenBetween(int $min, int $max): Validator { - return new FunctionValidator( - function (string $fieldName, string $str) use ($min, $max) { - $len = strlen($str); - if ($len >= $max) { - return [new FieldValidationFail($fieldName, "trop long, maximum $max caractères.")]; - } - if ($len < $min) { - return [new FieldValidationFail($fieldName, "trop court, minimum $min caractères.")]; - } - return []; - } - ); - } - - public static function email(?string $msg = null): Validator { - return new SimpleFunctionValidator( - fn(string $str) => filter_var($str, FILTER_VALIDATE_EMAIL), - fn(string $name) => [new FieldValidationFail($name, $msg == null ? "addresse mail invalide" : $msg)] - ); - } - - - public static function isInteger(): Validator { - return self::regex("/^[-+]?[0-9]+$/", "field is not an integer"); - } - - public static function isUnsignedInteger(): Validator { - return self::regex("/^[0-9]+$/", "field is not an unsigned integer"); - } - - public static function isIntInRange(int $min, int $max): Validator { - return new SimpleFunctionValidator( - fn(string $val) => intval($val) >= $min && intval($val) <= $max, - fn(string $name) => [new FieldValidationFail($name, "The value is not in the range $min to $max ")] - ); - } - - /** - * @param mixed[] $values - * @return Validator - */ - public static function oneOf(array $values): Validator { - return new SimpleFunctionValidator( - fn(string $val) => in_array($val, $values), - fn(string $name) => [new FieldValidationFail($name, "The value must be one of '" . join(", ", $values) . "'")] - ); - } - - public static function bool(): Validator { - return self::oneOf([true, false]); - } - - public static function isURL(): Validator { - return new SimpleFunctionValidator( - fn($val) => filter_var($val, FILTER_VALIDATE_URL), - fn(string $name) => [new FieldValidationFail($name, "The value is not an URL")] - ); - } - - /** - * @return Validator - */ - public static function array(): Validator { - return new SimpleFunctionValidator( - fn($val) => is_array($val), - fn(string $name) => [new FieldValidationFail($name, "The value is not an array")] - ); - } - - /** - * @param Validator $validator - * @return Validator - */ - public static function forall(Validator $validator): Validator { - return new class ($validator) extends Validator { - private Validator $validator; - - /** - * @param Validator $validator - */ - public function __construct(Validator $validator) { - $this->validator = $validator; - } - - public function validate(string $name, $val): array { - $failures = []; - foreach ($val as $idx => $item) { - $failures = array_merge($failures, $this->validator->validate($name . "[$idx]", $item)); - } - - return $failures; - } - }; - } -} diff --git a/src/Core/Validation/FieldValidationFail.php b/src/Core/Validation/FieldValidationFail.php deleted file mode 100644 index e3a127d..0000000 --- a/src/Core/Validation/FieldValidationFail.php +++ /dev/null @@ -1,42 +0,0 @@ -fieldName = $fieldName; - } - - public function getFieldName(): string { - return $this->fieldName; - } - - public static function invalidChars(string $fieldName): FieldValidationFail { - return new FieldValidationFail($fieldName, "field contains illegal chars"); - } - - public static function empty(string $fieldName): FieldValidationFail { - return new FieldValidationFail($fieldName, "field is empty"); - } - - public static function missing(string $fieldName): FieldValidationFail { - return new FieldValidationFail($fieldName, "field is missing"); - } - - /** - * @return array - */ - public function jsonSerialize(): array { - return ["field" => $this->fieldName, "message" => $this->getMessage()]; - } -} diff --git a/src/Core/Validation/FunctionValidator.php b/src/Core/Validation/FunctionValidator.php deleted file mode 100644 index 1bd18d7..0000000 --- a/src/Core/Validation/FunctionValidator.php +++ /dev/null @@ -1,21 +0,0 @@ -validate_fn = $validate_fn; - } - - public function validate(string $name, $val): array { - return call_user_func_array($this->validate_fn, [$name, $val]); - } -} diff --git a/src/Core/Validation/SimpleFunctionValidator.php b/src/Core/Validation/SimpleFunctionValidator.php deleted file mode 100644 index f19462b..0000000 --- a/src/Core/Validation/SimpleFunctionValidator.php +++ /dev/null @@ -1,33 +0,0 @@ - bool`, to validate the given string - * @param callable(string): ValidationFail[] $errorsFactory a factory function with signature `(string) => array` to emit failures when the predicate fails - */ - public function __construct(callable $predicate, callable $errorsFactory) { - $this->predicate = $predicate; - $this->errorFactory = $errorsFactory; - } - - public function validate(string $name, $val): array { - if (!call_user_func_array($this->predicate, [$val])) { - return call_user_func_array($this->errorFactory, [$name]); - } - return []; - } -} diff --git a/src/Core/Validation/Validation.php b/src/Core/Validation/Validation.php deleted file mode 100644 index 5b13354..0000000 --- a/src/Core/Validation/Validation.php +++ /dev/null @@ -1,29 +0,0 @@ -validate($valName, $val); - if ($error != null) { - $failures = array_merge($failures, $error); - $had_errors = true; - } - } - return $had_errors; - } - -} diff --git a/src/Core/Validation/ValidationFail.php b/src/Core/Validation/ValidationFail.php deleted file mode 100644 index 9a74a03..0000000 --- a/src/Core/Validation/ValidationFail.php +++ /dev/null @@ -1,56 +0,0 @@ -message = $message; - $this->kind = $kind; - } - - public function getMessage(): string { - return $this->message; - } - - public function getKind(): string { - return $this->kind; - } - - /** - * @return array - */ - public function jsonSerialize(): array { - return ["error" => $this->kind, "message" => $this->message]; - } - - /** - * @param string $message - * @return ValidationFail validation fail for unknown resource access - */ - public static function notFound(string $message): ValidationFail { - return new ValidationFail("Not found", $message); - } - - /** - * @param string $message - * @return ValidationFail validation fail for unauthorized accesses - */ - public static function unauthorized(string $message = "Unauthorized"): ValidationFail { - return new ValidationFail("Unauthorized", $message); - } - - public static function error(string $message): ValidationFail { - return new ValidationFail("Error", $message); - } - -} diff --git a/src/Core/Validation/Validator.php b/src/Core/Validation/Validator.php deleted file mode 100644 index d1761da..0000000 --- a/src/Core/Validation/Validator.php +++ /dev/null @@ -1,23 +0,0 @@ - \ No newline at end of file diff --git a/front/assets/icon/arrow.svg b/src/assets/icon/arrow.svg similarity index 100% rename from front/assets/icon/arrow.svg rename to src/assets/icon/arrow.svg diff --git a/front/assets/icon/ball.svg b/src/assets/icon/ball.svg similarity index 100% rename from front/assets/icon/ball.svg rename to src/assets/icon/ball.svg diff --git a/front/assets/icon/remove.svg b/src/assets/icon/remove.svg similarity index 100% rename from front/assets/icon/remove.svg rename to src/assets/icon/remove.svg diff --git a/front/assets/logo.svg b/src/assets/logo.svg similarity index 100% rename from front/assets/logo.svg rename to src/assets/logo.svg diff --git a/front/assets/logo192.png b/src/assets/logo192.png similarity index 100% rename from front/assets/logo192.png rename to src/assets/logo192.png diff --git a/front/assets/logo512.png b/src/assets/logo512.png similarity index 100% rename from front/assets/logo512.png rename to src/assets/logo512.png diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/components/Rack.tsx b/src/components/Rack.tsx similarity index 100% rename from front/components/Rack.tsx rename to src/components/Rack.tsx diff --git a/front/components/TitleInput.tsx b/src/components/TitleInput.tsx similarity index 100% rename from front/components/TitleInput.tsx rename to src/components/TitleInput.tsx diff --git a/front/components/actions/ArrowAction.tsx b/src/components/actions/ArrowAction.tsx similarity index 100% rename from front/components/actions/ArrowAction.tsx rename to src/components/actions/ArrowAction.tsx diff --git a/front/components/actions/BallAction.tsx b/src/components/actions/BallAction.tsx similarity index 100% rename from front/components/actions/BallAction.tsx rename to src/components/actions/BallAction.tsx diff --git a/front/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx similarity index 100% rename from front/components/arrows/BendableArrow.tsx rename to src/components/arrows/BendableArrow.tsx diff --git a/front/components/editor/BallPiece.tsx b/src/components/editor/BallPiece.tsx similarity index 100% rename from front/components/editor/BallPiece.tsx rename to src/components/editor/BallPiece.tsx diff --git a/front/components/editor/BasketCourt.tsx b/src/components/editor/BasketCourt.tsx similarity index 88% rename from front/components/editor/BasketCourt.tsx rename to src/components/editor/BasketCourt.tsx index 69bad37..2213525 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/src/components/editor/BasketCourt.tsx @@ -1,14 +1,7 @@ -import { - ReactElement, - ReactNode, - RefObject, - useEffect, - useLayoutEffect, - useState, -} from "react" +import { ReactElement, ReactNode, RefObject } from "react" import { Action } from "../../model/tactic/Action" -import { CourtAction } from "../../views/editor/CourtAction" +import { CourtAction } from "./CourtAction.tsx" import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" export interface BasketCourtProps { diff --git a/front/views/editor/CourtAction.tsx b/src/components/editor/CourtAction.tsx similarity index 100% rename from front/views/editor/CourtAction.tsx rename to src/components/editor/CourtAction.tsx diff --git a/front/components/editor/CourtBall.tsx b/src/components/editor/CourtBall.tsx similarity index 100% rename from front/components/editor/CourtBall.tsx rename to src/components/editor/CourtBall.tsx diff --git a/front/components/editor/CourtPlayer.tsx b/src/components/editor/CourtPlayer.tsx similarity index 100% rename from front/components/editor/CourtPlayer.tsx rename to src/components/editor/CourtPlayer.tsx diff --git a/front/components/editor/PlayerPiece.tsx b/src/components/editor/PlayerPiece.tsx similarity index 100% rename from front/components/editor/PlayerPiece.tsx rename to src/components/editor/PlayerPiece.tsx diff --git a/front/components/editor/SavingState.tsx b/src/components/editor/SavingState.tsx similarity index 100% rename from front/components/editor/SavingState.tsx rename to src/components/editor/SavingState.tsx diff --git a/front/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts similarity index 100% rename from front/editor/ActionsDomains.ts rename to src/editor/ActionsDomains.ts diff --git a/front/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts similarity index 100% rename from front/editor/PlayerDomains.ts rename to src/editor/PlayerDomains.ts diff --git a/front/editor/RackedItems.ts b/src/editor/RackedItems.ts similarity index 100% rename from front/editor/RackedItems.ts rename to src/editor/RackedItems.ts diff --git a/front/editor/TacticContentDomains.ts b/src/editor/StepContentDomains.ts similarity index 100% rename from front/editor/TacticContentDomains.ts rename to src/editor/StepContentDomains.ts diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts new file mode 100644 index 0000000..5839bee --- /dev/null +++ b/src/editor/TacticContentDomains.ts @@ -0,0 +1,243 @@ +import { Pos, ratioWithinBase } from "../geo/Pos" +import { + BallState, + Player, + PlayerInfo, + PlayerTeam, +} from "../model/tactic/Player" +import { + Ball, + BALL_ID, + BALL_TYPE, + CourtObject, +} from "../model/tactic/CourtObjects" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { RackedCourtObject, RackedPlayer } from "./RackedItems" +import { changePlayerBallState } from "./PlayerDomains" + +export function placePlayerAt( + refBounds: DOMRect, + courtBounds: DOMRect, + element: RackedPlayer, +): Player { + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + return { + type: "player", + id: "player-" + element.key + "-" + element.team, + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + ballState: BallState.NONE, + path: null, + actions: [], + } +} + +export function placeObjectAt( + refBounds: DOMRect, + courtBounds: DOMRect, + rackedObject: RackedCourtObject, + content: TacticContent, +): TacticContent { + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + let courtObject: CourtObject + + switch (rackedObject.key) { + case BALL_TYPE: + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content, true) + } + + courtObject = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + break + + default: + throw new Error("unknown court object " + rackedObject.key) + } + + return { + ...content, + components: [...content.components, courtObject], + } +} + +export function dropBallOnComponent( + targetedComponentIdx: number, + content: TacticContent, + setAsOrigin: boolean, +): TacticContent { + const component = content.components[targetedComponentIdx] + + if (component.type === "player" || component.type === "phantom") { + const newState = + setAsOrigin || + component.ballState === BallState.PASSED_ORIGIN || + component.ballState === BallState.HOLDS_ORIGIN + ? BallState.HOLDS_ORIGIN + : BallState.HOLDS_BY_PASS + + content = changePlayerBallState(component, newState, content) + } + + return removeBall(content) +} + +export function removeBall(content: TacticContent): TacticContent { + const ballObjIdx = content.components.findIndex((o) => o.type == "ball") + + if (ballObjIdx == -1) { + return content + } + + return { + ...content, + components: content.components.toSpliced(ballObjIdx, 1), + } +} + +export function placeBallAt( + refBounds: DOMRect, + courtBounds: DOMRect, + content: TacticContent, +): TacticContent { + if (!overlaps(courtBounds, refBounds)) { + return removeBall(content) + } + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content, true) + } + + const ballIdx = content.components.findIndex((o) => o.type == "ball") + + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + const ball: Ball = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + + let components = content.components + + if (ballIdx != -1) { + components = components.toSpliced(ballIdx, 1, ball) + } else { + components = components.concat(ball) + } + + return { + ...content, + components, + } +} + +export function moveComponent( + newPos: Pos, + component: TacticComponent, + info: PlayerInfo, + courtBounds: DOMRect, + content: TacticContent, + removed: (content: TacticContent) => TacticContent, +): TacticContent { + const playerBounds = document + .getElementById(info.id)! + .getBoundingClientRect() + + // if the piece is no longer on the court, remove it + if (!overlaps(playerBounds, courtBounds)) { + return removed(content) + } + return updateComponent( + { + ...component, + rightRatio: newPos.x, + bottomRatio: newPos.y, + }, + content, + ) +} + +export function removeComponent( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + return { + ...content, + components: content.components.filter((c) => c.id !== componentId), + } +} + +export function updateComponent( + component: TacticComponent, + content: TacticContent, +): TacticContent { + return { + ...content, + components: content.components.map((c) => + c.id === component.id ? component : c, + ), + } +} + +export function getComponentCollided( + bounds: DOMRect, + components: TacticComponent[], + ignore?: ComponentId, +): number | -1 { + for (let i = 0; i < components.length; i++) { + const component = components[i] + + if (component.id == ignore) continue + + const playerBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(playerBounds, bounds)) { + return i + } + } + return -1 +} + +export function getRackPlayers( + team: PlayerTeam, + components: TacticComponent[], +): RackedPlayer[] { + return ["1", "2", "3", "4", "5"] + .filter( + (role) => + components.findIndex( + (c) => + c.type == "player" && c.team == team && c.role == role, + ) == -1, + ) + .map((key) => ({ team, key })) +} diff --git a/front/geo/Box.ts b/src/geo/Box.ts similarity index 100% rename from front/geo/Box.ts rename to src/geo/Box.ts diff --git a/front/geo/Pos.ts b/src/geo/Pos.ts similarity index 100% rename from front/geo/Pos.ts rename to src/geo/Pos.ts diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..880d452 --- /dev/null +++ b/src/index.css @@ -0,0 +1,25 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + +} + +body { + margin: 0; + display: flex; + place-items: center; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..7229199 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import './index.css' +import App from "./App.tsx"; + + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/front/model/Team.ts b/src/model/Team.ts similarity index 100% rename from front/model/Team.ts rename to src/model/Team.ts diff --git a/front/model/User.ts b/src/model/User.ts similarity index 100% rename from front/model/User.ts rename to src/model/User.ts diff --git a/front/model/tactic/Action.ts b/src/model/tactic/Action.ts similarity index 100% rename from front/model/tactic/Action.ts rename to src/model/tactic/Action.ts diff --git a/front/model/tactic/CourtObjects.ts b/src/model/tactic/CourtObjects.ts similarity index 100% rename from front/model/tactic/CourtObjects.ts rename to src/model/tactic/CourtObjects.ts diff --git a/front/model/tactic/Player.ts b/src/model/tactic/Player.ts similarity index 100% rename from front/model/tactic/Player.ts rename to src/model/tactic/Player.ts diff --git a/front/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts similarity index 100% rename from front/model/tactic/Tactic.ts rename to src/model/tactic/Tactic.ts diff --git a/src/model/tactic/TacticInfo.ts b/src/model/tactic/TacticInfo.ts new file mode 100644 index 0000000..dfe1190 --- /dev/null +++ b/src/model/tactic/TacticInfo.ts @@ -0,0 +1,39 @@ +import { Player, PlayerPhantom } from "./Player" +import { Action } from "./Action" +import { CourtObject } from "./CourtObjects" + +export interface Tactic { + id: number + name: string + content: TacticContent +} + +export interface TacticContent { + components: TacticComponent[] + //actions: Action[] +} + +export type TacticComponent = Player | CourtObject | PlayerPhantom +export type ComponentId = string + +export interface Component { + /** + * The component's type + */ + readonly type: T + /** + * The component's identifier + */ + readonly id: ComponentId + /** + * Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + */ + readonly bottomRatio: number + + /** + * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number + + readonly actions: Action[] +} diff --git a/src/pages/404.tsx b/src/pages/404.tsx new file mode 100644 index 0000000..558b442 --- /dev/null +++ b/src/pages/404.tsx @@ -0,0 +1,11 @@ +import {useLocation} from "react-router-dom"; +import {BASE} from "../Constants.ts"; +export default function NotFoundPage() { + + const target = useLocation() + + return
+

{target.pathname} NOT FOUND !

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

Create Team Page

+} \ No newline at end of file diff --git a/front/views/Editor.tsx b/src/pages/Editor.tsx similarity index 95% rename from front/views/Editor.tsx rename to src/pages/Editor.tsx index 598f94a..15c9bfb 100644 --- a/front/views/Editor.tsx +++ b/src/pages/Editor.tsx @@ -28,7 +28,7 @@ import SavingState, { } from "../components/editor/SavingState" import { BALL_TYPE } from "../model/tactic/CourtObjects" -import { CourtAction } from "./editor/CourtAction" +import { CourtAction } from "../components/editor/CourtAction" import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" import { overlaps } from "../geo/Box" import { @@ -83,6 +83,20 @@ export interface EditorViewProps { courtType: "PLAIN" | "HALF" } + +export interface EditorPageProps { + courtType: "PLAIN" | "HALF" +} + +export default function EditorPage({ courtType }: EditorPageProps) { + return +} + export interface EditorProps { id: number name: string @@ -90,7 +104,7 @@ export interface EditorProps { courtType: "PLAIN" | "HALF" } -export default function Editor({ id, name, courtType, content }: EditorProps) { +function Editor({ id, name, courtType, content }: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) @@ -135,11 +149,11 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { } function EditorView({ - tactic: { id, name, content: initialContent }, - onContentChange, - onNameChange, - courtType, -}: EditorViewProps) { + tactic: { id, name, content: initialContent }, + onContentChange, + onNameChange, + courtType, + }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -520,12 +534,12 @@ interface PlayerRackProps { } function PlayerRack({ - id, - objects, - setObjects, - courtRef, - setComponents, -}: PlayerRackProps) { + id, + objects, + setObjects, + courtRef, + setComponents, + }: PlayerRackProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -579,15 +593,15 @@ interface CourtPlayerArrowActionProps { } function CourtPlayerArrowAction({ - playerInfo, - player, - isInvalid, - - content, - setContent, - setPreviewAction, - courtRef, -}: CourtPlayerArrowActionProps) { + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, + }: CourtPlayerArrowActionProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -754,4 +768,4 @@ function useContentState( ) return [content, setContentSynced, savingState] -} +} \ No newline at end of file diff --git a/front/views/Home.tsx b/src/pages/Home.tsx similarity index 95% rename from front/views/Home.tsx rename to src/pages/Home.tsx index a9a4b0a..df27c11 100644 --- a/front/views/Home.tsx +++ b/src/pages/Home.tsx @@ -1,8 +1,8 @@ import "../style/home/home.css" // import AccountSvg from "../assets/account.svg?react" -import { Header } from "./template/Header" -import { BASE } from "../Constants" +import {Header} from "./template/Header" +import {BASE} from "../Constants" interface Tactic { id: number @@ -18,7 +18,14 @@ interface Team { second_color: string } -export default function Home({ + +export default function HomePage() { + + console.log("HOME PAGE LOADED") + return +} + +function Home({ lastTactics, allTactics, teams, diff --git a/front/views/NewTacticPanel.tsx b/src/pages/NewTacticPage.tsx similarity index 97% rename from front/views/NewTacticPanel.tsx rename to src/pages/NewTacticPage.tsx index d02f314..2d056d8 100644 --- a/front/views/NewTacticPanel.tsx +++ b/src/pages/NewTacticPage.tsx @@ -5,7 +5,7 @@ import plainCourt from "../assets/court/full_court.svg" import halfCourt from "../assets/court/half_court.svg" import { BASE } from "../Constants" -export default function NewTacticPanel() { +export default function NewTacticPage() { return (
diff --git a/front/views/TeamPanel.tsx b/src/pages/TeamPanel.tsx similarity index 72% rename from front/views/TeamPanel.tsx rename to src/pages/TeamPanel.tsx index 709d7f2..e0e2baa 100644 --- a/front/views/TeamPanel.tsx +++ b/src/pages/TeamPanel.tsx @@ -1,13 +1,25 @@ import "../style/team_panel.css" -import { BASE } from "../Constants" -import { Team, TeamInfo, Member } from "../model/Team" -import { User } from "../model/User" +import {BASE} from "../Constants" +import {Member, Team, TeamInfo} from "../model/Team" +import {useParams} from "react-router-dom"; -export default function TeamPanel({ - isCoach, - team, - currentUserId, -}: { +export default function TeamPanelPage() { + const {teamId} = useParams() + const teamInfo = { + id: parseInt(teamId!), + name: teamId!, + mainColor: "#FFFFFF", + secondColor: "#000000", + picture: "https://a.espncdn.com/combiner/i?img=/i/teamlogos/nba/500/lal.png" + } + return +} + +function TeamPanel({ + isCoach, + team, + currentUserId, + }: { isCoach: boolean team: Team currentUserId: number @@ -19,9 +31,9 @@ export default function TeamPanel({ IQBall - + - {isCoach && } + {isCoach && }

{team.name}

- +
@@ -46,19 +58,19 @@ function TeamDisplay({ team }: { team: TeamInfo }) {

Couleur secondaire

- - + +
) } -function ColorDisplay({ color }: { color: string }) { - return
+function ColorDisplay({color}: { color: string }) { + return
} -function CoachOptions({ id }: { id: number }) { +function CoachOptions({id}: { id: number }) { return (
diff --git a/front/style/actions/arrow_action.css b/src/style/actions/arrow_action.css similarity index 100% rename from front/style/actions/arrow_action.css rename to src/style/actions/arrow_action.css diff --git a/front/style/actions/remove_action.css b/src/style/actions/remove_action.css similarity index 100% rename from front/style/actions/remove_action.css rename to src/style/actions/remove_action.css diff --git a/front/style/ball.css b/src/style/ball.css similarity index 100% rename from front/style/ball.css rename to src/style/ball.css diff --git a/front/style/bendable_arrows.css b/src/style/bendable_arrows.css similarity index 100% rename from front/style/bendable_arrows.css rename to src/style/bendable_arrows.css diff --git a/front/style/editor.css b/src/style/editor.css similarity index 99% rename from front/style/editor.css rename to src/style/editor.css index 5ba7596..b6a8ea4 100644 --- a/front/style/editor.css +++ b/src/style/editor.css @@ -23,6 +23,7 @@ } #topbar-div { + width: 100%; display: flex; background-color: var(--main-color); margin-bottom: 3px; diff --git a/front/style/home/home.css b/src/style/home/home.css similarity index 84% rename from front/style/home/home.css rename to src/style/home/home.css index 455e3af..0c4ecf7 100644 --- a/front/style/home/home.css +++ b/src/style/home/home.css @@ -1,4 +1,4 @@ -@import url(../theme/dark.css); +@import url(../theme/default.css); @import url(personnal_space.css); @import url(side_menu.css); @import url(../template/header.css); @@ -21,12 +21,12 @@ body { flex-direction: row; margin: 0px; height: 100%; - background-color: var(--second-color); + background-color: var(--home-second-color); } .data { border: 1.5px solid var(--main-contrast-color); - background-color: var(--main-color); + background-color: var(--home-main-color); border-radius: 0.75cap; color: var(--main-contrast-color); } diff --git a/front/style/home/personnal_space.css b/src/style/home/personnal_space.css similarity index 93% rename from front/style/home/personnal_space.css rename to src/style/home/personnal_space.css index 173098e..8598a0e 100644 --- a/front/style/home/personnal_space.css +++ b/src/style/home/personnal_space.css @@ -13,7 +13,7 @@ #body-personal-space { width: 95%; /* background-color: #ccc2b7; */ - border: 3px var(--main-color) solid; + border: 3px var(--home-main-color) solid; border-radius: 0.5cap; align-self: center; } diff --git a/front/style/home/side_menu.css b/src/style/home/side_menu.css similarity index 81% rename from front/style/home/side_menu.css rename to src/style/home/side_menu.css index 3a23947..aeb50b4 100644 --- a/front/style/home/side_menu.css +++ b/src/style/home/side_menu.css @@ -1,7 +1,7 @@ -@import url(../theme/dark.css); +@import url(../theme/default.css); #side-menu { - background-color: var(--third-color); + background-color: var(--home-third-color); display: flex; flex-direction: column; align-items: center; @@ -17,7 +17,7 @@ width: 90%; } .titre-side-menu { - border-bottom: var(--main-color) solid 3px; + border-bottom: var(--home-main-color) solid 3px; width: 100%; margin-bottom: 3%; } @@ -28,7 +28,7 @@ color: var(--main-contrast-color); letter-spacing: 1px; text-transform: uppercase; - background-color: var(--main-color); + background-color: var(--home-main-color); padding: 3%; margin-bottom: 0px; margin-right: 3%; diff --git a/front/style/new_tactic_panel.css b/src/style/new_tactic_panel.css similarity index 100% rename from front/style/new_tactic_panel.css rename to src/style/new_tactic_panel.css diff --git a/front/style/player.css b/src/style/player.css similarity index 100% rename from front/style/player.css rename to src/style/player.css diff --git a/src/style/steps_tree.css b/src/style/steps_tree.css new file mode 100644 index 0000000..eadeaf6 --- /dev/null +++ b/src/style/steps_tree.css @@ -0,0 +1,87 @@ +.step-piece { + position: relative; + font-family: monospace; + pointer-events: all; + + background-color: var(--editor-tree-step-piece); + color: var(--selected-team-secondarycolor); + + border-radius: 100px; + + width: 20px; + height: 20px; + + display: flex; + + align-items: center; + justify-content: center; + + user-select: none; + cursor: pointer; + + border: 2px solid var(--editor-tree-background); +} + +.step-piece-selected { + border: 2px solid var(--selection-color-light); +} + +.step-piece-selected, +.step-piece:focus, +.step-piece:hover { + background-color: var(--editor-tree-step-piece-hovered); +} + +.step-piece-actions { + display: none; + position: absolute; + column-gap: 5px; + top: -140%; +} + +.step-piece-selected .step-piece-actions { + display: flex; +} + +.add-icon, +.remove-icon { + background-color: white; + border-radius: 100%; +} + +.add-icon { + fill: var(--add-icon-fill); +} + +.remove-icon { + fill: var(--remove-icon-fill); +} + +.step-children { + margin-top: 10vh; + display: flex; + flex-direction: row; + width: 100%; + height: 100%; +} + +.step-group { + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + + width: 100%; + height: 100%; +} + +.steps-tree { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 10%; + + height: 100%; +} diff --git a/front/style/team_panel.css b/src/style/team_panel.css similarity index 100% rename from front/style/team_panel.css rename to src/style/team_panel.css diff --git a/front/style/template/header.css b/src/style/template/header.css similarity index 95% rename from front/style/template/header.css rename to src/style/template/header.css index 2ea8d2f..266b8c8 100644 --- a/front/style/template/header.css +++ b/src/style/template/header.css @@ -1,6 +1,6 @@ #header { text-align: center; - background-color: var(--main-color); + background-color: var(--home-main-color); margin: 0px; /* border : var(--accent-color) 1px solid; */ display: flex; diff --git a/front/style/theme/default.css b/src/style/theme/default.css similarity index 74% rename from front/style/theme/default.css rename to src/style/theme/default.css index 1885746..caa5162 100644 --- a/front/style/theme/default.css +++ b/src/style/theme/default.css @@ -21,4 +21,12 @@ --player-piece-ball-border-color: #000000; --text-main-font: "Roboto", sans-serif; + + --home-main-color: #191a21; + --home-second-color: #282a36; + --home-third-color: #303341; + --accent-color: #ffa238; + --main-contrast-color: #e6edf3; + --font-title: Helvetica; + --font-content: Helvetica; } diff --git a/front/style/title_input.css b/src/style/title_input.css similarity index 100% rename from front/style/title_input.css rename to src/style/title_input.css diff --git a/front/style/visualizer.css b/src/style/visualizer.css similarity index 100% rename from front/style/visualizer.css rename to src/style/visualizer.css diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json index d01f3cc..a7fc6fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,25 @@ { "compilerOptions": { - "target": "es2021", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "types": ["vite/client", "vite-plugin-svgr/client"], - "allowJs": true, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "module": "esnext", - "moduleResolution": "node", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true }, - "include": [ - "front" - ] + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/verify.sh b/verify.sh deleted file mode 100755 index 314b8bc..0000000 --- a/verify.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -## verify php and typescript types - -echo "running php typechecking" -vendor/bin/phpstan analyze && echo "php types are respected" - -echo "running typescript typechecking" -npm run tsc && echo "typescript types are respected" \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 4ff1dc5..596d3e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,47 +1,17 @@ -import {defineConfig} from "vite"; -import react from '@vitejs/plugin-react'; -import fs from "fs"; -import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; import svgr from "vite-plugin-svgr"; - -function resolve_entries(dirname: string): [string, string][] { - - //exclude assets - if (dirname == "front/assets" || dirname == "front/style") { - return [] - } - - return fs.readdirSync(dirname).flatMap(file_name => { - if (fs.lstatSync(`${dirname}/${file_name}`).isFile()) { - return [[`${dirname}/${file_name}`, `${dirname}/${file_name}`]] - } else { - return resolve_entries(`${dirname}/${file_name}`) - } - }) -} - +// https://vitejs.dev/config/ export default defineConfig({ - root: 'front', - base: '/front', - envDir: '..', - build: { - target: 'es2021', - assetsDir: '', - outDir: "../dist", - manifest: true, - rollupOptions: { - input: Object.fromEntries(resolve_entries("front")), - preserveEntrySignatures: "allow-extension", - } - }, - plugins: [ - react(), - cssInjectedByJsPlugin({ - relativeCSSInjection: true, - }), - svgr({ - include: "**/*.svg?react" - }) - ] + plugins: [ + react(), + cssInjectedByJsPlugin({ + relativeCSSInjection: true, + }), + svgr({ + include: "**/*.svg?react" + }) + ] }) From 9014a0e1542282db0d56d2ccbe809219bbe9c518 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 15 Feb 2024 00:27:03 +0100 Subject: [PATCH 34/44] add jwt authentication --- .env | 4 +- src/App.tsx | 37 +++++------ src/Fetcher.ts | 51 +++++++++++++-- src/api/failure.ts | 6 ++ src/api/session.ts | 23 +++++++ src/pages/{Home.tsx => HomePage.tsx} | 37 +++++++++-- src/pages/LoginPage.tsx | 63 +++++++++++++++++++ src/pages/RegisterPage.tsx | 92 ++++++++++++++++++++++++++++ src/style/form.css | 67 ++++++++++++++++++++ 9 files changed, 350 insertions(+), 30 deletions(-) create mode 100644 src/api/failure.ts create mode 100644 src/api/session.ts rename src/pages/{Home.tsx => HomePage.tsx} (85%) create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/RegisterPage.tsx create mode 100644 src/style/form.css diff --git a/.env b/.env index 98ae12d..88ae8ef 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -VITE_API_ENDPOINT=/api -VITE_BASE= \ No newline at end of file +VITE_API_ENDPOINT=http://localhost:5254 +VITE_BASE= diff --git a/src/App.tsx b/src/App.tsx index 843568d..b7441b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,11 @@ -import { - BrowserRouter, - createBrowserRouter, - createRoutesFromElements, - Route, - RouterProvider, - Routes -} from "react-router-dom"; +import { BrowserRouter, Route, Routes } from "react-router-dom" -import loadable from "@loadable/component"; +import loadable from "@loadable/component" -const HomePage = loadable(() => import("./pages/Home.tsx")) +const HomePage = loadable(() => import("./pages/HomePage.tsx")) +const LoginPage = loadable(() => import("./pages/LoginPage.tsx")) +const RegisterPage = loadable(() => import("./pages/RegisterPage.tsx")) const NotFoundPage = loadable(() => import("./pages/404.tsx")) const CreateTeamPage = loadable(() => import("./pages/CreateTeamPage.tsx")) const TeamPanelPage = loadable(() => import("./pages/TeamPanel.tsx")) @@ -19,17 +14,23 @@ const Editor = loadable(() => import("./pages/Editor.tsx")) export default function App() { + return ( -
+
- }/> - }/> - }/> - }/> - }/> - }/> - }/> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + + + } />
diff --git a/src/Fetcher.ts b/src/Fetcher.ts index 4c483e9..dd2b2b8 100644 --- a/src/Fetcher.ts +++ b/src/Fetcher.ts @@ -1,16 +1,57 @@ -import { API } from "./Constants" +import { API, BASE } from "./Constants" +import { Session } from "./api/session.ts" + + +export function redirect(url: string) { + location.pathname = BASE + url +} export function fetchAPI( url: string, payload: unknown, method = "POST", + session?: Session, ): Promise { + + const token = session?.auth?.token + + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + if (token) { + headers.Authorization = token + } + return fetch(`${API}/${url}`, { method, - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, + headers, body: JSON.stringify(payload), }) } + + +export function fetchAPIGet( + url: string, + session?: Session, +): Promise { + + const token = session?.auth?.token + + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + if (token) { + headers.Authorization = token + } + + return fetch(`${API}/${url}`, { + method: "GET", + headers, + }) +} + + diff --git a/src/api/failure.ts b/src/api/failure.ts new file mode 100644 index 0000000..7219ecd --- /dev/null +++ b/src/api/failure.ts @@ -0,0 +1,6 @@ + + +export interface Failure { + type: string + messages: string[] +} \ No newline at end of file diff --git a/src/api/session.ts b/src/api/session.ts new file mode 100644 index 0000000..506364c --- /dev/null +++ b/src/api/session.ts @@ -0,0 +1,23 @@ +import { createContext } from "react" + +export interface Session { + auth?: Authentication +} + +export interface Authentication { + token: string + expirationDate: Date +} + +const SESSION_KEY = "session" + +// export const SessionContext = createContext(getSession()) + +export function saveSession(session: Session) { + localStorage.setItem(SESSION_KEY, JSON.stringify(session)) +} + +export function getSession(): Session { + const json = localStorage.getItem(SESSION_KEY) + return json ? JSON.parse(json) : {} +} \ No newline at end of file diff --git a/src/pages/Home.tsx b/src/pages/HomePage.tsx similarity index 85% rename from src/pages/Home.tsx rename to src/pages/HomePage.tsx index df27c11..c3bb8c6 100644 --- a/src/pages/Home.tsx +++ b/src/pages/HomePage.tsx @@ -1,13 +1,17 @@ import "../style/home/home.css" // import AccountSvg from "../assets/account.svg?react" -import {Header} from "./template/Header" -import {BASE} from "../Constants" +import { Header } from "./template/Header" +import { BASE } from "../Constants" +import { getSession } from "../api/session.ts" +import { fetchAPIGet, redirect } from "../Fetcher.ts" +import { useLayoutEffect, useState } from "react" +import { User } from "../model/User.ts" interface Tactic { id: number name: string - creation_date: string + creationDate: Date } interface Team { @@ -21,8 +25,31 @@ interface Team { export default function HomePage() { - console.log("HOME PAGE LOADED") - return + type UserDataResponse = {user?: User, tactics: Tactic[], teams: Team[]} + const [{ user, tactics, teams }, setInfo] = useState({tactics: [], teams: []}) + + + useLayoutEffect(() => { + const session = getSession() + + if (!session.auth) { + redirect("/register") + return + } + + async function getUser() { + const response = await fetchAPIGet("user-data", session) + setInfo(await response.json()) + } + + getUser() + }, []) + + + console.log(user) + + const lastTactics = tactics!.sort((a, b) => a.creationDate.getMilliseconds() - b.creationDate.getMilliseconds()).slice(0, 5) + return } function Home({ diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..43a3a28 --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,63 @@ +import { FormEvent, useRef, useState } from "react" +import { BASE } from "../Constants.ts" +import { fetchAPI, redirect } from "../Fetcher.ts" +import { Failure } from "../api/failure.ts" +import { saveSession } from "../api/session.ts" +import "../style/form.css" + +export default function LoginApp() { + + + const [errors, setErrors] = useState([]) + + const emailRef = useRef(null) + const passwordRef = useRef(null) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + + const email = emailRef.current!.value + const password = passwordRef.current!.value + + const response = await fetchAPI("auth/token", {email, password}) + + if (response.ok) { + const { token, expirationDate } = await response.json() + saveSession({ auth: { token, expirationDate } }) + redirect("/") + return + } + + try { + const failures = await response.json() + setErrors(Object.entries(failures).map(([type, messages]) => ({ type, messages }))) + } catch (e) { + setErrors([{ type: "internal error", messages: ["an internal error occurred."] }]) + } + } + + return
+
+

Se connecter

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

{type} : {message}

))} + +
+
+ + + + + + + Vous n'avez pas de compte ? +

+
+ +
+
+
+
+} \ No newline at end of file diff --git a/src/pages/RegisterPage.tsx b/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..6cb76a2 --- /dev/null +++ b/src/pages/RegisterPage.tsx @@ -0,0 +1,92 @@ +import { FormEvent, useRef, useState } from "react" +import { BASE } from "../Constants.ts" + +import "../style/form.css" +import { Failure } from "../api/failure.ts" +import { fetchAPI, redirect } from "../Fetcher.ts" +import { saveSession } from "../api/session.ts" + +export default function RegisterPage() { + + const usernameField = useRef(null) + const passwordField = useRef(null) + const confirmpasswordField = useRef(null) + const emailField = useRef(null) + + + const [errors, setErrors] = useState([]) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + + const username = usernameField.current!.value + const password = passwordField.current!.value + const confirmpassword = confirmpasswordField.current!.value + const email = emailField.current!.value + + if (confirmpassword !== password) { + setErrors([{ + type: "password", + messages: ["le mot de passe et la confirmation du mot de passe ne sont pas equivalent."], + }]) + return + } + + const response = await fetchAPI("auth/register", { username, password, email }) + + if (response.ok) { + const { token, expirationDate } = await response.json() + saveSession({ auth: { token, expirationDate } }) + redirect("/") + return + } + + try { + const failures = await response.json() + setErrors(Object.entries(failures).map(([type, messages]) => ({ type, messages }))) + } catch (e) { + setErrors([{ type: "internal error", messages: ["an internal error occurred."] }]) + } + } + + + return
+
+

S'enregistrer

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

{type} : {message}

))} +
+ +
+
+ + + + + + + + + + + + + + + + Vous avez déjà un compte ? +
+
+ +
+
+
+} \ No newline at end of file diff --git a/src/style/form.css b/src/style/form.css new file mode 100644 index 0000000..3db12c0 --- /dev/null +++ b/src/style/form.css @@ -0,0 +1,67 @@ +body { + font-family: Arial, sans-serif; + background-color: #f1f1f1; +} + +.container { + max-width: 400px; + margin: 0 auto; + padding: 20px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +h2 { + text-align: center; +} + +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 5px; +} + +input[type="text"], input[type="password"] { + width: 95%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; +} + +.error-messages { + color: #ff331a; + font-style: italic; +} + +.inscr{ + font-size: small; + text-align: right; +} + +.consentement{ + font-size: small; +} + +#buttons{ + display: flex; + justify-content: center; + padding: 10px 20px; + +} + +.button{ + background-color: #007bff; + color: #fff; + padding: 10px 20px; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.button:hover{ + background-color: #0056b3; +} \ No newline at end of file From 87ddaacd976ebfc68536aec243e721bdc33da5c1 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 15 Feb 2024 13:58:22 +0100 Subject: [PATCH 35/44] add iqball header on all pages --- src/App.tsx | 5 ++++- src/index.css | 27 +++++++++++++------------ src/pages/Editor.tsx | 30 ++++----------------------- src/pages/HomePage.tsx | 10 ++------- src/pages/template/Header.tsx | 28 +++++++++++++++++++++----- src/style/home/home.css | 2 ++ src/style/template/header.css | 38 +++++++++++++++++------------------ 7 files changed, 67 insertions(+), 73 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b7441b5..13633cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom" import loadable from "@loadable/component" +import { Header } from "./pages/template/Header.tsx" const HomePage = loadable(() => import("./pages/HomePage.tsx")) @@ -15,9 +16,12 @@ const Editor = loadable(() => import("./pages/Editor.tsx")) export default function App() { + return (
+
+ } /> } /> @@ -29,7 +33,6 @@ export default function App() { } /> } /> - } /> diff --git a/src/index.css b/src/index.css index 880d452..7ee0cd3 100644 --- a/src/index.css +++ b/src/index.css @@ -1,25 +1,26 @@ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; - color-scheme: light dark; + color-scheme: light dark; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } body { - margin: 0; - display: flex; - place-items: center; + margin: 0; + display: flex; + place-items: center; + overflow-x: hidden; } h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 3.2em; + line-height: 1.1; } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 15c9bfb..288194c 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -22,10 +22,7 @@ import { PlayerPiece } from "../components/editor/PlayerPiece" import { Tactic, TacticComponent, TacticContent } from "../model/tactic/Tactic" import { fetchAPI } from "../Fetcher" -import SavingState, { - SaveState, - SaveStates, -} from "../components/editor/SavingState" +import SavingState, { SaveState, SaveStates } from "../components/editor/SavingState" import { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "../components/editor/CourtAction" @@ -42,32 +39,16 @@ import { removeBall, updateComponent, } from "../editor/TacticContentDomains" -import { - BallState, - Player, - PlayerInfo, - PlayerLike, - PlayerTeam, -} from "../model/tactic/Player" +import { BallState, Player, PlayerInfo, PlayerLike, PlayerTeam } from "../model/tactic/Player" import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import { - createAction, - getActionKind, - isActionValid, - removeAction, -} from "../editor/ActionsDomains" +import { createAction, getActionKind, isActionValid, removeAction } from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" -import { - changePlayerBallState, - getOrigin, - removePlayer, -} from "../editor/PlayerDomains" +import { changePlayerBallState, getOrigin, removePlayer } from "../editor/PlayerDomains" import { CourtBall } from "../components/editor/CourtBall" -import { BASE } from "../Constants" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -439,9 +420,6 @@ function EditorView({ return (
-
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index c3bb8c6..ce26803 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,7 +1,6 @@ import "../style/home/home.css" // import AccountSvg from "../assets/account.svg?react" -import { Header } from "./template/Header" import { BASE } from "../Constants" import { getSession } from "../api/session.ts" import { fetchAPIGet, redirect } from "../Fetcher.ts" @@ -26,7 +25,7 @@ interface Team { export default function HomePage() { type UserDataResponse = {user?: User, tactics: Tactic[], teams: Team[]} - const [{ user, tactics, teams }, setInfo] = useState({tactics: [], teams: []}) + const [{tactics, teams }, setInfo] = useState({tactics: [], teams: []}) useLayoutEffect(() => { @@ -46,26 +45,21 @@ export default function HomePage() { }, []) - console.log(user) - const lastTactics = tactics!.sort((a, b) => a.creationDate.getMilliseconds() - b.creationDate.getMilliseconds()).slice(0, 5) - return + return } function Home({ lastTactics, allTactics, teams, - username, }: { lastTactics: Tactic[] allTactics: Tactic[] teams: Team[] - username: string }) { return (
-
{ + async function getUsername() { + const response = await fetchAPIGet("user", getSession()) + //TODO check if the response is ok and handle errors + const {name} = await response.json() + setUsername(name) + } + + getUsername() + }, []) + return (