add save state information in topbar

pull/23/head
maxime.batista 2 years ago
parent 8dd6688cf8
commit 6731b02c6c

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

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

@ -1,9 +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;
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;
const xRatio = relativeXPixels / parent.width
const yRatio = relativeYPixels / parent.height
return {x: xRatio, y: yRatio}
return { x: xRatio, y: yRatio }
}

@ -17,7 +17,7 @@ export default function TitleInput({
return (
<input
className="title_input"
className="title-input"
ref={ref}
style={style}
type="text"

@ -1,28 +1,35 @@
import CourtSvg from '../../assets/basketball_court.svg?react';
import '../../style/basket_court.css';
import {useRef} from "react";
import CourtPlayer from "./CourtPlayer";
import {Player} from "../../tactic/Player";
import CourtSvg from "../../assets/basketball_court.svg?react"
import "../../style/basket_court.css"
import { useRef } from "react"
import CourtPlayer from "./CourtPlayer"
import { Player } from "../../tactic/Player"
export interface BasketCourtProps {
players: Player[],
onPlayerRemove: (p: Player) => void,
players: Player[]
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
}
export function BasketCourt({players, onPlayerRemove, onPlayerChange}: BasketCourtProps) {
const divRef = useRef<HTMLDivElement>(null);
export function BasketCourt({
players,
onPlayerRemove,
onPlayerChange,
}: BasketCourtProps) {
const divRef = useRef<HTMLDivElement>(null)
return (
<div id="court-container" ref={divRef} style={{position: "relative"}}>
<CourtSvg id="court-svg"/>
{players.map(player => {
return <CourtPlayer key={player.id}
<div id="court-container" ref={divRef} style={{ position: "relative" }}>
<CourtSvg id="court-svg" />
{players.map((player) => {
return (
<CourtPlayer
key={player.id}
player={player}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
parentRef={divRef}
/>
)
})}
</div>
)

@ -1,5 +1,4 @@
import { MutableRefObject, useEffect, useRef, useState } 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"
@ -8,10 +7,10 @@ import { Player } from "../../tactic/Player"
import { calculateRatio } from "../../Utils"
export interface PlayerProps {
player: Player,
onChange: (p: Player) => void,
player: Player
onChange: (p: Player) => void
onRemove: () => void
parentRef: MutableRefObject<HTMLElement>
parentRef: RefObject<HTMLDivElement>
}
/**

@ -1,6 +1,6 @@
import React from "react"
import "../../style/player.css"
import { Team } from "../../data/Team"
import { Team } from "../../tactic/Team"
export function PlayerPiece({ team, text }: { team: Team; text: string }) {
return (

@ -0,0 +1,27 @@
export interface SaveState {
className: string
message: string
}
export class SaveStates {
static readonly Ok: SaveState = {
className: "save-state-ok",
message: "saved",
}
static readonly Saving: SaveState = {
className: "save-state-saving",
message: "saving...",
}
static readonly Err: SaveState = {
className: "save-state-error",
message: "could not save tactic.",
}
}
export default function SavingState({ state }: { state: SaveState }) {
return (
<div className={"save-state"}>
<div className={state.className}>{state.message}</div>
</div>
)
}

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

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

@ -1,8 +1,8 @@
import {Player} from "./Player";
import { Player } from "./Player"
export interface Tactic {
id: number,
name: string,
id: number
name: string
content: TacticContent
}

@ -1,24 +1,25 @@
import {CSSProperties, useEffect, useRef, useState} from "react";
import "../style/editor.css";
import TitleInput from "../components/TitleInput";
import {BasketCourt} from "../components/editor/BasketCourt";
import React, { CSSProperties, useEffect, useRef, useState } from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import { BasketCourt } from "../components/editor/BasketCourt"
import {Rack} from "../components/Rack";
import {PlayerPiece} from "../components/editor/PlayerPiece";
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import {Player} from "../tactic/Player";
import {Tactic, TacticContent} from "../tactic/Tactic";
import {fetchAPI} from "../Fetcher";
import {Team} from "../tactic/Team";
import {calculateRatio} from "../Utils";
import { 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, { SaveStates } from "../components/editor/SavingState"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
}
export interface EditorViewProps {
tactic: Tactic,
onContentChange: (tactic: TacticContent) => Promise<boolean>,
tactic: Tactic
onContentChange: (tactic: TacticContent) => Promise<boolean>
onNameChange: (name: string) => Promise<boolean>
}
@ -30,39 +31,78 @@ interface RackedPlayer {
key: string
}
export default function Editor({tactic}: { tactic: Tactic }) {
return <EditorView tactic={tactic}
onContentChange={(content: TacticContent) => (
fetchAPI(`tactic/${tactic.id}/save`, {content})
.then((r) => r.ok)
)}
onNameChange={(name: string) => (
fetchAPI(`tactic/${tactic.id}/edit/name`, {name})
.then((r) => r.ok)
)}/>
export default function Editor({
id,
name,
content,
}: {
id: number
name: string
content: string
}) {
return (
<EditorView
tactic={{ name, id, content: JSON.parse(content) }}
onContentChange={(content: TacticContent) =>
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}, onContentChange, onNameChange}: EditorViewProps) {
const [style, setStyle] = useState<CSSProperties>({});
function EditorView({
tactic: { name, content },
onContentChange,
onNameChange,
}: EditorViewProps) {
const [style, setStyle] = useState<CSSProperties>({})
const [saveState, setSaveState] = useState(SaveStates.Ok)
const positions = ["1", "2", "3", "4", "5"]
const [allies, setAllies] = useState(
positions.map((key) => ({ team: Team.Allies, key })),
positions
.filter(
(role) =>
content.players.findIndex(
(p) => p.team == Team.Allies && p.role == role,
) == -1,
)
.map((key) => ({ team: Team.Allies, key })),
)
const [opponents, setOpponents] = useState(
positions.map((key) => ({ team: Team.Opponents, key })),
positions
.filter(
(role) =>
content.players.findIndex(
(p) => p.team == Team.Opponents && p.role == role,
) == -1,
)
.map((key) => ({ team: Team.Opponents, key })),
)
const [players, setPlayers] = useState<Player[]>(content.players)
const courtDivContentRef = useRef<HTMLDivElement>(null)
const [players, setPlayers] = useState<Player[]>(content.players);
const courtDivContentRef = useRef<HTMLDivElement>(null);
// The didMount ref is used to store a boolean flag in order to avoid calling 'onChange' when the editor is first rendered.
const didMount = useRef(false)
useEffect(() => {
onContentChange({players})
.then(success => {
if (!success)
alert("error when saving changes.")
if (!didMount.current) {
didMount.current = true
return
}
setSaveState(SaveStates.Saving)
onContentChange({ players })
.then((success) => {
if (success) {
setSaveState(SaveStates.Ok)
} else {
setSaveState(SaveStates.Err)
}
})
.catch(() => setSaveState(SaveStates.Err))
}, [players])
const canDetach = (ref: HTMLDivElement) => {
@ -82,34 +122,45 @@ function EditorView({tactic: {name, content}, onContentChange, onNameChange}: Ed
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = calculateRatio(refBounds, courtBounds)
const {x, y} = calculateRatio(refBounds, courtBounds)
setPlayers(players => {
return [...players, {
setPlayers((players) => {
return [
...players,
{
id: players.length,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y
}]
bottomRatio: y,
},
]
})
}
return (
<div id="main-div">
<div id="topbar-div">
<div>LEFT</div>
<TitleInput style={style} default_value={name} on_validated={new_name => {
onNameChange(new_name).then(success => {
<div id="topbar-left">
LEFT
<SavingState state={saveState} />
</div>
<div id="title-input-div">
<TitleInput
style={style}
default_value={name}
on_validated={(new_name) => {
onNameChange(new_name).then((success) => {
if (success) {
setStyle({})
} else {
setStyle(ERROR_STYLE)
}
})
}}/>
<div>RIGHT</div>
}}
/>
</div>
<div id="topbar-right">RIGHT</div>
</div>
<div id="edit-div">
<div id="racks">
@ -139,14 +190,18 @@ function EditorView({tactic: {name, content}, onContentChange, onNameChange}: Ed
<BasketCourt
players={players}
onPlayerChange={(player) => {
setPlayers(players => {
const idx = players.indexOf(player)
setPlayers((players) => {
const idx = players.findIndex(
(p) => p.id === player.id,
)
return players.toSpliced(idx, 1, player)
})
}}
onPlayerRemove={(player) => {
setPlayers((players) => {
const idx = players.indexOf(player)
const idx = players.findIndex(
(p) => p.id === player.id,
)
return players.toSpliced(idx, 1)
})
switch (player.team) {

@ -31,7 +31,7 @@ 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) => getTacticController()->saveContent($id)));
$router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc)));
return $router;
}

@ -19,10 +19,15 @@ 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
@ -35,7 +40,8 @@ CREATE TABLE Team
);
CREATE TABLE Member(
CREATE TABLE Member
(
id_team integer,
id_user integer,
role char(1) CHECK (role IN ('Coach', 'Player')),

@ -52,6 +52,11 @@ class APITacticController {
* @return HttpResponse
*/
public function saveContent(int $id, Account $account): HttpResponse {
return Control::runChecked([
"content" => [],
], function (HttpRequest $req) use ($id) {
$this->model->updateContent($id, json_encode($req["content"]));
return HttpResponse::fromCode(HttpCodes::OK);
});
}
}

@ -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(),
]);
}
/**

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

@ -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,11 +67,7 @@ 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());
}
/**
@ -90,4 +86,14 @@ class TacticInfoGateway {
);
}
public function updateContent(int $id, string $json): void {
$this->con->exec(
"UPDATE Tactic SET content = :content WHERE id = :id",
[
":content" => [$json, PDO::PARAM_STR],
":id" => [$id, PDO::PARAM_INT],
]
);
}
}

@ -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);
}
/**
@ -79,4 +80,8 @@ class TacticModel {
return [];
}
public function updateContent(int $id, string $json): void {
$this->tactics->updateContent($id, $json);
}
}

Loading…
Cancel
Save