diff --git a/Documentation/models.puml b/Documentation/models.puml index c0629d4..86ca699 100755 --- a/Documentation/models.puml +++ b/Documentation/models.puml @@ -5,11 +5,13 @@ class TacticInfo { - name: string - creationDate: string - ownerId: string + - content: string + getId(): int + getOwnerId(): int + getCreationTimestamp(): int + getName(): string + + getContent(): string } class Account { diff --git a/front/Fetcher.ts b/front/Fetcher.ts new file mode 100644 index 0000000..4c483e9 --- /dev/null +++ b/front/Fetcher.ts @@ -0,0 +1,16 @@ +import { API } from "./Constants" + +export function fetchAPI( + url: string, + payload: unknown, + method = "POST", +): Promise { + return fetch(`${API}/${url}`, { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) +} diff --git a/front/Utils.ts b/front/Utils.ts new file mode 100644 index 0000000..523d813 --- /dev/null +++ b/front/Utils.ts @@ -0,0 +1,12 @@ +export function calculateRatio( + it: { x: number; y: number }, + parent: DOMRect, +): { x: number; y: number } { + const relativeXPixels = it.x - parent.x + const relativeYPixels = it.y - parent.y + + const xRatio = relativeXPixels / parent.width + const yRatio = relativeYPixels / parent.height + + return { x: xRatio, y: yRatio } +} diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index 6e4acb0..8da1c65 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -17,7 +17,7 @@ export default function TitleInput({ return ( void + onPlayerChange: (p: Player) => void } -export function BasketCourt({ players, onPlayerRemove }: BasketCourtProps) { +export function BasketCourt({ + players, + onPlayerRemove, + onPlayerChange, +}: BasketCourtProps) { + const divRef = useRef(null) + return ( -
+
{players.map((player) => { return ( onPlayerRemove(player)} + parentRef={divRef} /> ) })} diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 9b08e7b..12083d3 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,28 +1,53 @@ -import { useRef } from "react" +import { RefObject, useRef, useState } from "react" import "../../style/player.css" import RemoveIcon from "../../assets/icon/remove.svg?react" import Draggable from "react-draggable" import { PlayerPiece } from "./PlayerPiece" -import { Player } from "../../data/Player" +import { Player } from "../../tactic/Player" +import { calculateRatio } from "../../Utils" export interface PlayerProps { player: Player + onChange: (p: Player) => void onRemove: () => void + parentRef: RefObject } /** * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ -export default function CourtPlayer({ player, onRemove }: PlayerProps) { - const ref = useRef(null) +export default function CourtPlayer({ + player, + onChange, + onRemove, + parentRef, +}: PlayerProps) { + const pieceRef = useRef(null) const x = player.rightRatio const y = player.bottomRatio return ( - + { + const pieceBounds = pieceRef.current!.getBoundingClientRect() + const parentBounds = parentRef.current!.getBoundingClientRect() + + const { x, y } = calculateRatio(pieceBounds, parentBounds) + + onChange({ + rightRatio: x, + bottomRatio: y, + team: player.team, + role: player.role, + }) + }}>
+
{state.message}
+
+ ) +} diff --git a/front/style/editor.css b/front/style/editor.css index b586a36..d832fc3 100644 --- a/front/style/editor.css +++ b/front/style/editor.css @@ -9,9 +9,21 @@ flex-direction: column; } +#topbar-left { + width: 100%; + display: flex; +} + +#topbar-right { + width: 100%; + display: flex; + flex-direction: row-reverse; +} + #topbar-div { display: flex; background-color: var(--main-color); + margin-bottom: 3px; justify-content: space-between; align-items: stretch; @@ -22,8 +34,9 @@ justify-content: space-between; } -.title_input { +.title-input { width: 25ch; + align-self: center; } #edit-div { @@ -56,3 +69,22 @@ .react-draggable { z-index: 2; } + +.save-state { + display: flex; + align-items: center; + margin-left: 20%; + font-family: monospace; +} + +.save-state-error { + color: red; +} + +.save-state-ok { + color: green; +} + +.save-state-saving { + color: gray; +} diff --git a/front/style/title_input.css b/front/style/title_input.css index 1b6be10..6d28238 100644 --- a/front/style/title_input.css +++ b/front/style/title_input.css @@ -1,4 +1,4 @@ -.title_input { +.title-input { background: transparent; border-top: none; border-right: none; @@ -9,7 +9,7 @@ border-bottom-color: transparent; } -.title_input:focus { +.title-input:focus { outline: none; border-bottom-color: blueviolet; diff --git a/front/data/Player.ts b/front/tactic/Player.ts similarity index 73% rename from front/data/Player.ts rename to front/tactic/Player.ts index f2667b9..6530612 100644 --- a/front/data/Player.ts +++ b/front/tactic/Player.ts @@ -1,19 +1,13 @@ import { Team } from "./Team" export interface Player { - /** - * unique identifier of the player. - * This identifier must be unique to the associated court. - */ - id: number - /** * the player's team * */ team: Team /** - * player's position + * player's role * */ role: string diff --git a/front/tactic/Tactic.ts b/front/tactic/Tactic.ts new file mode 100644 index 0000000..bb2cd37 --- /dev/null +++ b/front/tactic/Tactic.ts @@ -0,0 +1,11 @@ +import { Player } from "./Player" + +export interface Tactic { + id: number + name: string + content: TacticContent +} + +export interface TacticContent { + players: Player[] +} diff --git a/front/data/Team.tsx b/front/tactic/Team.tsx similarity index 100% rename from front/data/Team.tsx rename to front/tactic/Team.tsx diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index d98062d..dddf5cc 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,18 +1,38 @@ -import { CSSProperties, useRef, useState } from "react" +import { + CSSProperties, + Dispatch, + SetStateAction, + useCallback, + useRef, + useState, +} from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" -import { API } from "../Constants" import { BasketCourt } from "../components/editor/BasketCourt" import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" -import { Player } from "../data/Player" -import { Team } from "../data/Team" + +import { Player } from "../tactic/Player" +import { Tactic, TacticContent } from "../tactic/Tactic" +import { fetchAPI } from "../Fetcher" +import { Team } from "../tactic/Team" +import { calculateRatio } from "../Utils" +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" const ERROR_STYLE: CSSProperties = { borderColor: "red", } +export interface EditorViewProps { + tactic: Tactic + onContentChange: (tactic: TacticContent) => Promise + onNameChange: (name: string) => Promise +} + /** * information about a player that is into a rack */ @@ -21,17 +41,48 @@ interface RackedPlayer { key: string } -export default function Editor({ id, name }: { id: number; name: string }) { +export default function Editor({ + id, + name, + content, +}: { + id: number + name: string + content: string +}) { + return ( + + fetchAPI(`tactic/${id}/save`, { content }).then((r) => r.ok) + } + onNameChange={(name: string) => + fetchAPI(`tactic/${id}/edit/name`, { name }).then((r) => r.ok) + } + /> + ) +} + +function EditorView({ + tactic: { name, content: initialContent }, + onContentChange, + onNameChange, +}: EditorViewProps) { const [style, setStyle] = useState({}) - const positions = ["1", "2", "3", "4", "5"] + const [content, setContent, saveState] = useContentState( + initialContent, + (content) => + onContentChange(content).then((success) => + success ? SaveStates.Ok : SaveStates.Err, + ), + ) const [allies, setAllies] = useState( - positions.map((key) => ({ team: Team.Allies, key })), + getRackPlayers(Team.Allies, content.players), ) const [opponents, setOpponents] = useState( - positions.map((key) => ({ team: Team.Opponents, key })), + getRackPlayers(Team.Opponents, content.players), ) - const [players, setPlayers] = useState([]) const courtDivContentRef = useRef(null) const canDetach = (ref: HTMLDivElement) => { @@ -51,53 +102,42 @@ export default function Editor({ id, name }: { id: number; name: string }) { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const relativeXPixels = refBounds.x - courtBounds.x - const relativeYPixels = refBounds.y - courtBounds.y - - const xRatio = relativeXPixels / courtBounds.width - const yRatio = relativeYPixels / courtBounds.height - - setPlayers((players) => { - return [ - ...players, - { - id: players.length, - team: element.team, - role: element.key, - rightRatio: xRatio, - bottomRatio: yRatio, - }, - ] + const { x, y } = calculateRatio(refBounds, courtBounds) + + setContent((content) => { + return { + players: [ + ...content.players, + { + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + }, + ], + } }) } return (
-
LEFT
- { - fetch(`${API}/tactic/${id}/edit/name`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: new_name, - }), - }).then((response) => { - if (response.ok) { - setStyle({}) - } else { - setStyle(ERROR_STYLE) - } - }) - }} - /> -
RIGHT
+
+ LEFT + +
+
+ { + onNameChange(new_name).then((success) => { + setStyle(success ? {} : ERROR_STYLE) + }) + }} + /> +
+
RIGHT
@@ -125,33 +165,40 @@ export default function Editor({ id, name }: { id: number; name: string }) {
{ + setContent((content) => ({ + players: toSplicedPlayers( + content.players, + player, + true, + ), + })) + }} onPlayerRemove={(player) => { - setPlayers((players) => { - const idx = players.indexOf(player) - return players.toSpliced(idx, 1) - }) + setContent((content) => ({ + players: toSplicedPlayers( + content.players, + player, + false, + ), + })) + let setter switch (player.team) { case Team.Opponents: - setOpponents((opponents) => [ - ...opponents, - { - team: player.team, - pos: player.role, - key: player.role, - }, - ]) + setter = setOpponents break case Team.Allies: - setAllies((allies) => [ - ...allies, - { - team: player.team, - pos: player.role, - key: player.role, - }, - ]) + setter = setAllies } + setter((players) => [ + ...players, + { + team: player.team, + pos: player.role, + key: player.role, + }, + ]) }} />
@@ -160,3 +207,51 @@ export default function Editor({ id, name }: { id: number; name: string }) {
) } + +function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { + return ["1", "2", "3", "4", "5"] + .filter( + (role) => + players.findIndex((p) => p.team == team && p.role == role) == + -1, + ) + .map((key) => ({ team, key })) +} + +function useContentState( + initialContent: S, + saveStateCallback: (s: S) => Promise, +): [S, Dispatch>, SaveState] { + const [content, setContent] = useState(initialContent) + const [savingState, setSavingState] = useState(SaveStates.Ok) + + const setContentSynced = useCallback((newState: SetStateAction) => { + setContent((content) => { + const state = + typeof newState === "function" + ? (newState as (state: S) => S)(content) + : newState + if (state !== content) { + setSavingState(SaveStates.Saving) + saveStateCallback(state) + .then(setSavingState) + .catch(() => setSavingState(SaveStates.Err)) + } + return state + }) + }, [saveStateCallback]) + + return [content, setContentSynced, savingState] +} + +function toSplicedPlayers( + players: Player[], + player: Player, + replace: boolean, +): Player[] { + const idx = players.findIndex( + (p) => p.team === player.team && p.role === player.role, + ) + + return players.toSpliced(idx, 1, ...(replace ? [player] : [])) +} diff --git a/public/api/index.php b/public/api/index.php index fd39bfa..5734571 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -29,8 +29,9 @@ function getRoutes(): AltoRouter { $router = new AltoRouter(); $router->setBasePath(get_public_path(__DIR__)); - $router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); $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))); return $router; } diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 324fb39..eb74877 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -19,26 +19,32 @@ CREATE TABLE Tactic name varchar NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, owner integer NOT NULL, + content varchar DEFAULT '{"players": []}' NOT NULL, FOREIGN KEY (owner) REFERENCES Account ); -CREATE TABLE FormEntries(name varchar, description varchar); +CREATE TABLE FormEntries +( + name varchar, + description varchar +); CREATE TABLE Team ( - id integer PRIMARY KEY AUTOINCREMENT, - name varchar, - picture varchar, + id integer PRIMARY KEY AUTOINCREMENT, + name varchar, + picture varchar, main_color varchar, second_color varchar ); -CREATE TABLE Member( +CREATE TABLE Member +( id_team integer, id_user integer, - role char(1) CHECK (role IN ('Coach', 'Player')), + role text CHECK (role IN ('Coach', 'Player')), FOREIGN KEY (id_team) REFERENCES Team (id), FOREIGN KEY (id_user) REFERENCES User (id) ); diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index e8a1731..79e766c 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -9,6 +9,7 @@ use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Model\TacticModel; +use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\Validators; /** @@ -45,4 +46,20 @@ class APITacticController { return HttpResponse::fromCode(HttpCodes::OK); }); } + + /** + * @param int $id + * @param Account $account + * @return HttpResponse + */ + public function saveContent(int $id, Account $account): HttpResponse { + return Control::runChecked([ + "content" => [], + ], function (HttpRequest $req) use ($id) { + if ($fail = $this->model->updateContent($id, json_encode($req["content"]))) { + return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); + } + return HttpResponse::fromCode(HttpCodes::OK); + }); + } } diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index bba3214..d2a2bc4 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -8,7 +8,6 @@ use IQBall\App\ViewHttpResponse; use IQBall\Core\Data\TacticInfo; use IQBall\Core\Http\HttpCodes; use IQBall\Core\Model\TacticModel; -use IQBall\Core\Validation\ValidationFail; class EditorController { private TacticModel $model; @@ -22,7 +21,11 @@ class EditorController { * @return ViewHttpResponse the editor view for given tactic */ private function openEditorFor(TacticInfo $tactic): ViewHttpResponse { - return ViewHttpResponse::react("views/Editor.tsx", ["name" => $tactic->getName(), "id" => $tactic->getId()]); + return ViewHttpResponse::react("views/Editor.tsx", [ + "id" => $tactic->getId(), + "name" => $tactic->getName(), + "content" => $tactic->getContent(), + ]); } /** diff --git a/src/Core/Connection.php b/src/Core/Connection.php index 019b515..1dd559d 100644 --- a/src/Core/Connection.php +++ b/src/Core/Connection.php @@ -25,7 +25,7 @@ class Connection { * @return void */ public function exec(string $query, array $args) { - $stmnt = $this->prepare($query, $args); + $stmnt = $this->prep($query, $args); $stmnt->execute(); } @@ -36,7 +36,7 @@ class Connection { * @return array[] the returned rows of the request */ public function fetch(string $query, array $args): array { - $stmnt = $this->prepare($query, $args); + $stmnt = $this->prep($query, $args); $stmnt->execute(); return $stmnt->fetchAll(PDO::FETCH_ASSOC); } @@ -46,7 +46,7 @@ class Connection { * @param array> $args * @return \PDOStatement */ - private function prepare(string $query, array $args): \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]); @@ -54,4 +54,8 @@ class Connection { return $stmnt; } + public function prepare(string $query): \PDOStatement { + return $this->pdo->prepare($query); + } + } diff --git a/src/Core/Data/TacticInfo.php b/src/Core/Data/TacticInfo.php index 04f592a..2565f93 100644 --- a/src/Core/Data/TacticInfo.php +++ b/src/Core/Data/TacticInfo.php @@ -8,17 +8,28 @@ class TacticInfo { private int $creationDate; private int $ownerId; + private string $content; + /** * @param int $id * @param string $name * @param int $creationDate * @param int $ownerId + * @param string $content */ - public function __construct(int $id, string $name, int $creationDate, int $ownerId) { + public function __construct(int $id, string $name, int $creationDate, int $ownerId, string $content) { $this->id = $id; $this->name = $name; $this->ownerId = $ownerId; $this->creationDate = $creationDate; + $this->content = $content; + } + + /** + * @return string + */ + public function getContent(): string { + return $this->content; } public function getId(): int { @@ -36,8 +47,10 @@ class TacticInfo { return $this->ownerId; } - public function getCreationTimestamp(): int { + /** + * @return int + */ + public function getCreationDate(): int { return $this->creationDate; } - } diff --git a/src/Core/Gateway/TacticInfoGateway.php b/src/Core/Gateway/TacticInfoGateway.php index f3d2432..6b66f2c 100644 --- a/src/Core/Gateway/TacticInfoGateway.php +++ b/src/Core/Gateway/TacticInfoGateway.php @@ -33,7 +33,7 @@ class TacticInfoGateway { $row = $res[0]; - return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"]); + return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"], $row['content']); } @@ -57,9 +57,9 @@ class TacticInfoGateway { /** * @param string $name * @param int $owner - * @return TacticInfo + * @return int inserted tactic id */ - public function insert(string $name, int $owner): TacticInfo { + public function insert(string $name, int $owner): int { $this->con->exec( "INSERT INTO Tactic(name, owner) VALUES(:name, :owner)", [ @@ -67,27 +67,37 @@ class TacticInfoGateway { ":owner" => [$owner, PDO::PARAM_INT], ] ); - $row = $this->con->fetch( - "SELECT id, creation_date, owner FROM Tactic WHERE :id = id", - [':id' => [$this->con->lastInsertId(), PDO::PARAM_INT]] - )[0]; - return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"]), $row["owner"]); + return intval($this->con->lastInsertId()); } /** * update name of given tactic identifier * @param int $id * @param string $name - * @return void + * @return bool */ - public function updateName(int $id, string $name): void { - $this->con->exec( - "UPDATE Tactic SET name = :name WHERE id = :id", - [ - ":name" => [$name, PDO::PARAM_STR], - ":id" => [$id, PDO::PARAM_INT], - ] - ); + 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/Model/TacticModel.php b/src/Core/Model/TacticModel.php index 64c6ca3..136b27d 100644 --- a/src/Core/Model/TacticModel.php +++ b/src/Core/Model/TacticModel.php @@ -2,9 +2,9 @@ namespace IQBall\Core\Model; +use IQBall\Core\Data\TacticInfo; use IQBall\Core\Gateway\TacticInfoGateway; use IQBall\Core\Validation\ValidationFail; -use IQBall\Core\Data\TacticInfo; class TacticModel { public const TACTIC_DEFAULT_NAME = "Nouvelle tactique"; @@ -26,7 +26,8 @@ class TacticModel { * @return TacticInfo */ public function makeNew(string $name, int $ownerId): TacticInfo { - return $this->tactics->insert($name, $ownerId); + $id = $this->tactics->insert($name, $ownerId); + return $this->tactics->get($id); } /** @@ -75,8 +76,17 @@ class TacticModel { return [ValidationFail::unauthorized()]; } - $this->tactics->updateName($id, $name); + 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/Validation/ValidationFail.php b/src/Core/Validation/ValidationFail.php index 9a714e5..9a74a03 100644 --- a/src/Core/Validation/ValidationFail.php +++ b/src/Core/Validation/ValidationFail.php @@ -49,4 +49,8 @@ class ValidationFail implements JsonSerializable { return new ValidationFail("Unauthorized", $message); } + public static function error(string $message): ValidationFail { + return new ValidationFail("Error", $message); + } + }