Add possibility to edit tactics that takes place on a semi court #37

Merged
maxime.batista merged 6 commits from editor/half-court into master 1 year ago

@ -1 +1,2 @@
VITE_API_ENDPOINT=/api
VITE_API_ENDPOINT=/api
VITE_BASE=

@ -36,6 +36,7 @@ steps:
- chmod +x /tmp/moshell_setup.sh
- 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
-
- /root/.local/bin/moshell ci/build_react.msh

@ -9,8 +9,6 @@ val drone_branch = std::env("DRONE_BRANCH").unwrap()
val base = "/IQBall/$drone_branch/public"
npm run build -- --base=$base --mode PROD
npm run build -- --base=/IQBall/public --mode PROD
// Read generated mappings from build
val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json)
val mappings = $result.split('\n')

@ -2,3 +2,8 @@
* This constant defines the API endpoint.
*/
export const API = import.meta.env.VITE_API_ENDPOINT
/**
* This constant defines the base app's endpoint.
*/
export const BASE = import.meta.env.VITE_BASE

@ -1,7 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="100%"
width="100"
height="50"
viewBox="7.5 18.5 85.5 56"
style="enable-background:new 7.5 18.5 85.5 56;"
xml:space="preserve">

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

@ -0,0 +1,22 @@
<svg width="100" height="50" viewBox="0 0 80 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.74466 0.676824V28.6768V56.6768L76.7447 56.6768V28.6768V0.676824L2.74466 0.676824Z" stroke="black" stroke-miterlimit="10"/>
<path d="M76.8393 0.876801L3.21608 0.876801" stroke="black" stroke-miterlimit="10"/>
<path d="M12.0393 56.8768V47.2768C12.0393 33.8101 24.6232 23.0101 39.9554 23.0101C55.2875 23.0101 67.8715 33.9435 67.8715 47.2768V56.8768" stroke="black" stroke-miterlimit="10"/>
<path d="M49.3571 56.8768V31.5435C49.3571 26.7435 45.1625 23.0101 40.1 23.0101C35.0375 23.0101 30.8429 26.8768 30.8429 31.5435V56.8768" stroke="black" stroke-miterlimit="10"/>
<path d="M49.3571 31.5435H30.6982" stroke="black" stroke-miterlimit="10"/>
<path d="M49.2125 31.1435C49.2125 31.5435 49.2125 31.8101 49.2125 32.0768" stroke="black" stroke-miterlimit="10"/>
<path d="M48.6339 33.9435C47.3322 37.2768 43.8607 39.6768 39.8107 39.6768C35.4715 39.6768 31.7107 36.7435 30.8429 33.0102" stroke="black" stroke-miterlimit="10" stroke-dasharray="1.44 1.44"/>
<path d="M30.8429 32.0768C30.8429 31.8101 30.8429 31.4101 30.8429 31.1435" stroke="black" stroke-miterlimit="10"/>
<path d="M30.6982 46.0768H29.2518" stroke="black" stroke-miterlimit="10"/>
<path d="M30.6982 41.9435H29.2518" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M30.6982 37.8102H29.2518" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M30.6982 33.4102H29.2518" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M50.8036 46.2101H49.3572" stroke="black" stroke-miterlimit="10"/>
<path d="M50.8036 42.0768H49.3572" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M50.8036 37.9435H49.3572" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M50.8036 33.5435H49.3572" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M49.3571 31.5435H30.6982" stroke="black" stroke-miterlimit="10"/>
<path d="M6.25357 19.4102H0.323216" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M79.8768 19.4102H73.9464" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/>
<path d="M30.7447 0.67682C30.7447 5.64738 34.998 9.67682 40.2447 9.67682C45.4914 9.67682 49.7447 5.64738 49.7447 0.67682" stroke="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -1,6 +1,5 @@
import CourtSvg from "../../assets/basketball_court.svg?react"
import "../../style/basket_court.css"
import { useRef } from "react"
import { RefObject, useRef } from "react"
import CourtPlayer from "./CourtPlayer"
import { Player } from "../../tactic/Player"
@ -8,18 +7,23 @@ export interface BasketCourtProps {
players: Player[]
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
courtImage: string
courtRef: RefObject<HTMLDivElement>
}
export function BasketCourt({
players,
onPlayerRemove,
onPlayerChange,
courtImage,
courtRef,
}: BasketCourtProps) {
const divRef = useRef<HTMLDivElement>(null)
return (
<div id="court-container" ref={divRef} style={{ position: "relative" }}>
<CourtSvg id="court-svg" />
<div
id="court-container"
ref={courtRef}
style={{ position: "relative" }}>
<img src={courtImage} alt={"court"} id="court-svg" />
{players.map((player) => {
return (
<CourtPlayer
@ -27,7 +31,7 @@ export function BasketCourt({
player={player}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
parentRef={divRef}
parentRef={courtRef}
/>
)
})}

@ -1,11 +1,16 @@
#court-container {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
height: 100%;
background-color: var(--main-color);
}
#court-svg {
margin: 5%;
margin: 35px 0 35px 0;
height: 87%;
user-select: none;
-webkit-user-drag: none;
}

@ -1,11 +0,0 @@
:root {
--main-color: #ffffff;
--second-color: #ccde54;
--background-color: #d2cdd3;
--selected-team-primarycolor: #ffffff;
--selected-team-secondarycolor: #000000;
--selection-color: #3f7fc4;
}

@ -1,4 +1,4 @@
@import "colors.css";
@import "theme/default.css";
#main-div {
display: flex;
@ -63,7 +63,8 @@
}
#court-div-bounds {
width: 60%;
padding: 20px 20px 20px 20px;
height: 75%;
}
.react-draggable {

@ -0,0 +1,122 @@
#panel-root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
align-content: center;
}
#panel-top {
font-family: var(--text-main-font);
}
#panel-choices {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
align-content: center;
background-color: var(--editor-court-selection-background);
}
#panel-buttons {
width: 75%;
height: 20%;
display: flex;
justify-content: space-evenly;
align-items: stretch;
align-content: center;
}
.court-kind-button {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
align-content: center;
cursor: pointer;
transition: scale 0.5s ease-out;
width: auto;
}
.court-kind-button-bottom,
.court-kind-button-top {
border: solid;
border-color: var(--border-color);
}
.court-kind-button-bottom {
display: flex;
justify-content: center;
align-items: center;
align-content: center;
height: 25%;
width: 100%;
background-color: var(--editor-court-selection-buttons);
border-radius: 0 0 20px 20px;
border-width: 3px;
}
.court-kind-button-top {
height: 30%;
background-color: var(--main-color);
border-radius: 20px 20px 0 0;
border-width: 3px 3px 0 3px;
}
.court-kind-button:hover {
scale: 1.1;
}
.court-kind-button-top,
.court-kind-button-image-div {
overflow: hidden;
display: flex;
height: 100%;
width: 100%;
justify-content: center;
align-items: center;
align-content: center;
}
.court-kind-button-image {
height: 100%;
width: 150px;
user-select: none;
-webkit-user-drag: none;
}
.court-kind-button-image-div {
height: 100%;
padding: 0 10px 0 10px;
background-color: var(--second-color);
}
.court-kind-button-name,
.court-kind-button-details {
user-select: none;
font-family: var(--text-main-font);
}
.court-kind-button-details {
position: absolute;
z-index: -1;
top: 0;
transition: top 1s;
}
.court-kind-button:hover .court-kind-button-details {
top: -20px;
}

@ -39,12 +39,11 @@ on the court.
}
.player-selection-tab {
display: flex;
display: none;
position: absolute;
margin-bottom: 10%;
justify-content: center;
visibility: hidden;
width: 100%;
transform: translateY(-20px);
@ -67,7 +66,7 @@ on the court.
}
.player:focus-within .player-selection-tab {
visibility: visible;
display: flex;
}
.player:focus-within .player-piece {

@ -0,0 +1,21 @@
@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@300;400&display=swap");
:root {
--main-color: #ffffff;
--second-color: #e8e8e8;
--background-color: #d2cdd3;
--selected-team-primarycolor: #ffffff;
--selected-team-secondarycolor: #000000;
--buttons-shadow-color: #a8a8a8;
--selection-color: #3f7fc4;
--border-color: #ffffff;
--editor-court-selection-background: #5f8fee;
--editor-court-selection-buttons: #acc4f3;
--text-main-font: "Roboto", sans-serif;
}

@ -10,6 +10,9 @@ import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import { BasketCourt } from "../components/editor/BasketCourt"
import plainCourt from "../assets/court/court.svg"
import halfCourt from "../assets/court/half_court.svg"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
@ -34,6 +37,14 @@ export interface EditorViewProps {
tactic: Tactic
onContentChange: (tactic: TacticContent) => Promise<SaveState>
onNameChange: (name: string) => Promise<boolean>
courtType: "PLAIN" | "HALF"
}
export interface EditorProps {
id: number
name: string
content: string
courtType: "PLAIN" | "HALF"
}
/**
@ -47,12 +58,9 @@ interface RackedPlayer {
export default function Editor({
id,
name,
courtType,
content,
}: {
id: number
name: string
content: string
}) {
}: EditorProps) {
const isInGuestMode = id == -1
const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
@ -86,7 +94,7 @@ export default function Editor({
(r) => r.ok,
)
}}
/>
courtType={courtType}/>
)
}
@ -94,15 +102,17 @@ function EditorView({
tactic: { id, name, content: initialContent },
onContentChange,
onNameChange,
courtType,
}: EditorViewProps) {
const isInGuestMode = id == -1
const [style, setStyle] = useState<CSSProperties>({})
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [content, setContent, saveState] = useContentState(
initialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
onContentChange,
)
const [allies, setAllies] = useState(
getRackPlayers(Team.Allies, content.players),
)
@ -155,11 +165,11 @@ function EditorView({
</div>
<div id="title-input-div">
<TitleInput
style={style}
style={titleStyle}
default_value={name}
on_validated={(new_name) => {
onNameChange(new_name).then((success) => {
setStyle(success ? {} : ERROR_STYLE)
setTitleStyle(success ? {} : ERROR_STYLE)
})
}}
/>
@ -190,9 +200,13 @@ function EditorView({
/>
</div>
<div id="court-div">
<div id="court-div-bounds" ref={courtDivContentRef}>
<div id="court-div-bounds">
<BasketCourt
players={content.players}
courtImage={
courtType == "PLAIN" ? plainCourt : halfCourt
}
courtRef={courtDivContentRef}
onPlayerChange={(player) => {
setContent((content) => ({
players: toSplicedPlayers(

@ -0,0 +1,63 @@
import "../style/theme/default.css"
import "../style/new_tactic_panel.css"
import plainCourt from "../assets/court/court.svg"
import halfCourt from "../assets/court/half_court.svg"
import {BASE} from "../Constants";
export default function NewTacticPanel() {
return (
<div id={"panel-root"}>
<div id={"panel-top"}>
<p>Select a basket court</p>
</div>
<div id={"panel-choices"}>
<div id={"panel-buttons"}>
<CourtKindButton
name="Plain"
details="Select a plain basketball court"
image={plainCourt}
redirect="/tactic/new/plain"
/>
<CourtKindButton
name="Half"
details="Select half a basketball court"
image={halfCourt}
redirect="/tactic/new/half"
/>
</div>
</div>
</div>
)
}
function CourtKindButton({
name,
image,
details,
redirect,
}: {
name: string
image: string
details: string
redirect: string
}) {
return (
<div
className="court-kind-button"
onClick={() => location.href = BASE + redirect}>
<div className="court-kind-button-details">{details}</div>
<div className="court-kind-button-top">
<div className="court-kind-button-image-div">
<img
src={image}
alt={name}
className="court-kind-button-image"/>
</div>
</div>
<div className="court-kind-button-bottom">
<p className="court-kind-button-name">{name}</p>
</div>
</div>
)
}

@ -1,6 +1,6 @@
import React, { CSSProperties, useState } from "react"
import "../style/visualizer.css"
import Court from "../assets/basketball_court.svg"
import Court from "../assets/court/court.svg"
export default function Visualizer({ id, name }: { id: number; name: string }) {
const [style, setStyle] = useState<CSSProperties>({})

@ -19,6 +19,7 @@ use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Action;
use IQBall\Core\Connection;
use IQBall\Core\Data\CourtType;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Gateway\MemberGateway;
use IQBall\Core\Gateway\TacticInfoGateway;
@ -90,7 +91,9 @@ function getRoutes(): AltoRouter {
$ar->map("GET", "/tactic/[i:id]/edit", Action::auth(fn(int $id, SessionHandle $s) => getEditorController()->openEditor($id, $s)));
// don't require an authentication to run this action.
// If the user is not connected, the tactic will never save.
$ar->map("GET", "/tactic/new", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNew($s)));
$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)));

@ -16,35 +16,36 @@ CREATE TABLE Account
CREATE TABLE Tactic
(
id integer PRIMARY KEY AUTOINCREMENT,
name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer NOT NULL,
content varchar DEFAULT '{"players": []}' NOT NULL,
name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer NOT NULL,
content varchar DEFAULT '{"players": []}' NOT NULL,
court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
FOREIGN KEY (owner) REFERENCES Account
);
CREATE TABLE FormEntries
(
name varchar,
description varchar
name varchar NOT NULL,
description varchar NOT NULL
);
CREATE TABLE Team
(
id integer PRIMARY KEY AUTOINCREMENT,
name varchar,
picture varchar,
main_color varchar,
second_color varchar
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,
id_user integer,
role text CHECK (role IN ('Coach', 'Player')),
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 User (id)
FOREIGN KEY (id_user) REFERENCES Account (id)
);

@ -5,9 +5,11 @@ namespace IQBall\App\Controller;
use IQBall\App\Session\SessionHandle;
use IQBall\App\Validator\TacticValidator;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Data\CourtType;
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;
@ -25,17 +27,23 @@ class EditorController {
"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(): ViewHttpResponse {
private function openTestEditor(CourtType $courtType): ViewHttpResponse {
return ViewHttpResponse::react("views/Editor.tsx", [
"id" => -1, //-1 id means that the editor will not support saves
"content" => '{"players": []}',
"name" => TacticModel::TACTIC_DEFAULT_NAME,
"content" => '{"players": []}',
"courtType" => $courtType->name()
]);
}
@ -44,15 +52,18 @@ class EditorController {
* 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 createNew(SessionHandle $session): ViewHttpResponse {
$account = $session->getAccount();
public function createNewOfKind(CourtType $type, SessionHandle $session): ViewHttpResponse {
$action = $session->getAccount();
if ($account == null) {
return $this->openTestEditor();
if ($action == null) {
return $this->openTestEditor($type);
}
$tactic = $this->model->makeNewDefault($account->getId());
$tactic = $this->model->makeNewDefault($session->getAccount()->getId(), $type);
return $this->openEditorFor($tactic);
}

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

@ -35,18 +35,18 @@ final class MemberRole {
public function name(): string {
switch ($this->value) {
case self::ROLE_COACH:
return "Coach";
return "COACH";
case self::ROLE_PLAYER:
return "Player";
return "PLAYER";
}
die("unreachable");
}
public static function fromName(string $name): ?MemberRole {
switch ($name) {
case "Coach":
case "COACH":
return MemberRole::coach();
case "Player":
case "PLAYER":
return MemberRole::player();
default:
return null;

@ -7,7 +7,7 @@ class TacticInfo {
private string $name;
private int $creationDate;
private int $ownerId;
private CourtType $courtType;
private string $content;
/**
@ -15,13 +15,15 @@ class TacticInfo {
* @param string $name
* @param int $creationDate
* @param int $ownerId
* @param CourtType $type
* @param string $content
*/
public function __construct(int $id, string $name, int $creationDate, int $ownerId, string $content) {
public function __construct(int $id, string $name, int $creationDate, int $ownerId, CourtType $type, string $content) {
$this->id = $id;
$this->name = $name;
$this->ownerId = $ownerId;
$this->creationDate = $creationDate;
$this->courtType = $type;
$this->content = $content;
}
@ -47,6 +49,10 @@ class TacticInfo {
return $this->ownerId;
}
public function getCourtType(): CourtType {
return $this->courtType;
}
/**
* @return int
*/

@ -3,6 +3,7 @@
namespace IQBall\Core\Gateway;
use IQBall\Core\Connection;
use IQBall\Core\Data\CourtType;
use IQBall\Core\Data\TacticInfo;
use PDO;
@ -33,7 +34,8 @@ class TacticInfoGateway {
$row = $res[0];
return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"], $row['content']);
$type = CourtType::fromName($row['court_type']);
return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"], $type, $row['content']);
}
@ -57,14 +59,16 @@ class TacticInfoGateway {
/**
* @param string $name
* @param int $owner
* @param CourtType $type
* @return int inserted tactic id
*/
public function insert(string $name, int $owner): int {
public function insert(string $name, int $owner, CourtType $type): int {
$this->con->exec(
"INSERT INTO Tactic(name, owner) VALUES(:name, :owner)",
"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());

@ -2,6 +2,7 @@
namespace IQBall\Core\Model;
use IQBall\Core\Data\CourtType;
use IQBall\Core\Data\TacticInfo;
use IQBall\Core\Gateway\TacticInfoGateway;
use IQBall\Core\Validation\ValidationFail;
@ -23,20 +24,22 @@ class TacticModel {
* 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): TacticInfo {
$id = $this->tactics->insert($name, $ownerId);
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): ?TacticInfo {
return $this->makeNew(self::TACTIC_DEFAULT_NAME, $ownerId);
public function makeNewDefault(int $ownerId, CourtType $type): ?TacticInfo {
return $this->makeNew(self::TACTIC_DEFAULT_NAME, $ownerId, $type);
}
/**

Loading…
Cancel
Save