pull/84/head
Maël DAIM 1 year ago
commit d78b51c015

@ -1 +1,2 @@
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')

@ -4,6 +4,9 @@ mkdir ~/.ssh
echo "$SERVER_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 0600 ~/.ssh
chmod 0500 ~/.ssh/id_rsa*
ssh -p 80 -o 'StrictHostKeyChecking=no' iqball@maxou.dev mkdir -p /server/nginx/IQBall/$DRONE_BRANCH
rsync -avz -e "ssh -p 80 -o 'StrictHostKeyChecking=no'" --delete /outputs/* iqball@maxou.dev:/server/nginx/IQBall/$DRONE_BRANCH
ssh -p 80 -o 'StrictHostKeyChecking=no' iqball@maxou.dev "chmod 770 /server/nginx/IQBall/$DRONE_BRANCH; chgrp www-data /server/nginx/IQBall/$DRONE_BRANCH"
SERVER_ROOT=/srv/www/IQBall
ssh -p 80 -o 'StrictHostKeyChecking=no' iqball@maxou.dev mkdir -p $SERVER_ROOT/$DRONE_BRANCH
rsync -avz -e "ssh -p 80 -o 'StrictHostKeyChecking=no'" --delete /outputs/* iqball@maxou.dev:$SERVER_ROOT/$DRONE_BRANCH
ssh -p 80 -o 'StrictHostKeyChecking=no' iqball@maxou.dev "chmod 777 $SERVER_ROOT/$DRONE_BRANCH"

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

@ -0,0 +1,62 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1280.000000pt" height="1276.000000pt" viewBox="0 0 1280.000000 1276.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1276.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M6085 12754 c-1124 -66 -2124 -378 -3055 -952 -113 -70 -150 -98
-115 -88 50 14 350 37 555 43 925 24 1755 -198 2573 -691 95 -56 321 -202 502
-322 511 -340 600 -373 753 -280 281 170 481 457 559 800 24 105 24 393 -1
506 -59 277 -202 557 -417 813 -45 54 -91 102 -103 106 -19 8 -160 25 -411 51
-99 11 -724 21 -840 14z"/>
<path d="M7630 12635 c0 -2 28 -48 63 -102 210 -324 335 -626 378 -913 16
-112 14 -310 -6 -424 -56 -328 -245 -651 -591 -1014 -79 -83 -105 -117 -98
-127 11 -17 953 -895 959 -895 3 0 136 105 297 233 1085 863 1485 1140 2065
1431 94 47 173 91 175 98 7 20 -232 240 -467 429 -766 616 -1654 1043 -2622
1259 -135 31 -153 33 -153 25z"/>
<path d="M3055 11615 c-127 -13 -262 -36 -359 -61 -69 -18 -157 -81 -397 -280
-984 -823 -1695 -1909 -2049 -3135 -84 -291 -140 -550 -185 -854 -19 -130 -29
-231 -23 -225 3 3 16 34 28 70 36 103 124 267 195 363 386 518 1151 901 2475
1238 291 74 553 134 910 209 107 23 236 50 285 60 50 11 158 33 240 50 83 17
191 39 240 50 50 10 133 28 185 39 1079 228 1599 392 1915 604 96 64 205 181
244 262 28 57 35 82 35 135 1 62 -2 70 -44 135 -142 218 -526 495 -1019 737
-920 449 -1941 680 -2676 603z"/>
<path d="M10940 10674 c-63 -41 -176 -114 -250 -162 -299 -193 -798 -547
-1215 -861 -423 -318 -899 -711 -893 -736 2 -6 189 -246 417 -532 l414 -520
171 82 c528 255 975 375 1398 375 574 0 1060 -239 1464 -720 85 -101 219 -286
264 -365 18 -31 34 -54 36 -52 11 11 -69 456 -120 671 -235 976 -692 1875
-1347 2646 -54 63 -126 145 -161 182 l-63 66 -115 -74z"/>
<path d="M7125 9833 c-70 -62 -231 -181 -327 -242 -491 -308 -1120 -531 -2106
-746 -90 -20 -452 -94 -804 -165 -1128 -228 -1491 -317 -1978 -485 -936 -323
-1527 -761 -1860 -1378 l-42 -78 -5 -252 c-31 -1503 472 -2974 1415 -4137 47
-58 87 -107 88 -108 8 -11 50 35 210 233 1067 1318 2042 2408 3174 3549 953
960 1908 1846 2933 2721 135 116 246 215 247 220 0 10 -890 905 -900 905 -3
-1 -23 -17 -45 -37z"/>
<path d="M8255 8618 c-886 -668 -1534 -1216 -2370 -2004 -1028 -969 -2125
-2136 -3299 -3509 -132 -154 -265 -309 -296 -345 -142 -164 -592 -703 -596
-715 -7 -16 300 -326 466 -471 678 -593 1425 -1020 2260 -1294 231 -75 490
-143 730 -190 207 -40 413 -72 560 -85 62 -6 69 -4 155 38 192 94 346 209 515
383 351 363 601 854 839 1649 58 192 158 587 211 835 40 185 114 541 190 920
98 486 168 825 200 965 5 22 16 69 24 105 8 36 36 151 61 255 40 164 59 235
115 435 52 187 169 532 242 715 232 582 562 1062 911 1325 25 19 45 38 44 42
-2 8 -838 1039 -840 1037 -1 0 -56 -41 -122 -91z"/>
<path d="M10570 8093 c-358 -54 -579 -149 -741 -319 -84 -89 -93 -106 -86
-168 10 -100 65 -199 397 -721 368 -578 508 -818 691 -1185 379 -760 567
-1413 611 -2132 24 -392 -30 -863 -142 -1243 -16 -54 -28 -100 -27 -102 8 -8
297 369 419 547 722 1055 1109 2313 1108 3600 0 294 -15 490 -40 547 -30 68
-127 228 -194 318 -240 326 -590 581 -999 729 -271 98 -498 137 -791 135 -100
-1 -192 -3 -206 -6z"/>
<path d="M9265 7317 c-292 -97 -541 -403 -744 -914 -174 -439 -312 -964 -521
-1975 -16 -82 -44 -214 -60 -295 -17 -82 -39 -188 -50 -238 -11 -49 -33 -157
-50 -240 -389 -1893 -798 -2915 -1390 -3478 -57 -54 -123 -112 -147 -130 l-45
-32 203 -3 c568 -9 1247 95 1844 283 928 292 1741 765 2451 1423 179 166 216
207 253 281 96 190 163 413 202 676 20 128 23 192 23 430 0 344 -22 560 -95
927 -191 959 -696 2109 -1249 2843 -175 231 -351 410 -428 433 -64 19 -153 23
-197 9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@ -0,0 +1,21 @@
import React, { RefObject } from "react"
import "../../style/ball.css"
import Ball from "../../assets/icon/ball.svg?react"
import Draggable from "react-draggable"
export interface BallPieceProps {
onDrop: () => void
pieceRef: RefObject<HTMLDivElement>
}
export function BallPiece({ onDrop, pieceRef }: BallPieceProps) {
return (
<Draggable onStop={onDrop} nodeRef={pieceRef} position={{ x: 0, y: 0 }}>
<div className={`ball-div`} ref={pieceRef}>
<Ball className={"ball"} />
</div>
</Draggable>
)
}

@ -1,25 +1,32 @@
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"
export interface BasketCourtProps {
players: Player[]
onPlayerRemove: (p: Player) => void
onBallDrop: (ref: HTMLDivElement) => void
onPlayerChange: (p: Player) => void
courtImage: string
courtRef: RefObject<HTMLDivElement>
}
export function BasketCourt({
players,
onPlayerRemove,
onBallDrop,
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 +34,8 @@ export function BasketCourt({
player={player}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
parentRef={divRef}
onBallDrop={onBallDrop}
parentRef={courtRef}
/>
)
})}

@ -1,6 +1,7 @@
import { RefObject, useRef, useState } from "react"
import "../../style/player.css"
import RemoveIcon from "../../assets/icon/remove.svg?react"
import { BallPiece } from "./BallPiece"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../tactic/Player"
@ -10,6 +11,7 @@ export interface PlayerProps {
player: Player
onChange: (p: Player) => void
onRemove: () => void
onBallDrop: (ref: HTMLDivElement) => void
parentRef: RefObject<HTMLDivElement>
}
@ -20,12 +22,15 @@ export default function CourtPlayer({
player,
onChange,
onRemove,
onBallDrop,
parentRef,
}: PlayerProps) {
const pieceRef = useRef<HTMLDivElement>(null)
const ballPiece = useRef<HTMLDivElement>(null)
const x = player.rightRatio
const y = player.bottomRatio
const hasBall = player.hasBall
return (
<Draggable
@ -40,10 +45,12 @@ export default function CourtPlayer({
const { x, y } = calculateRatio(pieceBounds, parentBounds)
onChange({
id: player.id,
rightRatio: x,
bottomRatio: y,
team: player.team,
role: player.role,
hasBall: player.hasBall,
})
}}>
<div
@ -55,6 +62,7 @@ export default function CourtPlayer({
top: `${y * 100}%`,
}}>
<div
id={player.id}
tabIndex={0}
className="player-content"
onKeyUp={(e) => {
@ -65,8 +73,18 @@ export default function CourtPlayer({
className="player-selection-tab-remove"
onClick={onRemove}
/>
{hasBall && (
<BallPiece
onDrop={() => onBallDrop(ballPiece.current!)}
pieceRef={ballPiece}
/>
)}
</div>
<PlayerPiece team={player.team} text={player.role} />
<PlayerPiece
team={player.team}
text={player.role}
hasBall={hasBall}
/>
</div>
</div>
</Draggable>

@ -2,9 +2,20 @@ import React from "react"
import "../../style/player.css"
import { Team } from "../../tactic/Team"
export function PlayerPiece({ team, text }: { team: Team; text: string }) {
export interface PlayerPieceProps {
team: Team
text: string
hasBall: boolean
}
export function PlayerPiece({ team, text, hasBall }: PlayerPieceProps) {
let className = `player-piece ${team}`
if (hasBall) {
className += ` player-piece-has-ball`
}
return (
<div className={`player-piece ${team}`}>
<div className={className}>
<p>{text}</p>
</div>
)

@ -4,6 +4,10 @@ export interface SaveState {
}
export class SaveStates {
static readonly Guest: SaveState = {
className: "save-state-guest",
message: "you are not connected, your changes will not be saved.",
}
static readonly Ok: SaveState = {
className: "save-state-ok",
message: "saved",

@ -0,0 +1,11 @@
.ball * {
fill: #c5520d;
}
.ball-div,
.ball {
pointer-events: all;
width: 20px;
height: 20px;
cursor: pointer;
}

@ -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;
@ -49,7 +49,11 @@
}
.player-piece.opponents {
background-color: #f59264;
background-color: var(--player-opponents-color);
}
.player-piece.allies {
background-color: var(--player-allies-color);
}
#court-div {
@ -63,7 +67,8 @@
}
#court-div-bounds {
width: 60%;
padding: 20px 20px 20px 20px;
height: 75%;
}
.react-draggable {
@ -85,6 +90,7 @@
color: green;
}
.save-state-saving {
.save-state-saving,
.save-state-guest {
color: gray;
}

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

@ -23,9 +23,7 @@ on the court.
background-color: var(--selected-team-primarycolor);
color: var(--selected-team-secondarycolor);
border-width: 2px;
border-radius: 100px;
border-style: solid;
width: 20px;
height: 20px;
@ -38,21 +36,29 @@ on the court.
user-select: none;
}
.player-piece-has-ball {
border-width: 2px;
border-style: solid;
border-color: var(--player-piece-ball-border-color);
}
.player-selection-tab {
display: flex;
display: none;
position: absolute;
margin-bottom: 10%;
margin-bottom: -20%;
justify-content: center;
visibility: hidden;
width: 100%;
width: fit-content;
transform: translateY(-20px);
}
.player-selection-tab-remove {
visibility: hidden;
pointer-events: all;
height: 25%;
width: 25px;
height: 17px;
justify-content: center;
}
.player-selection-tab-remove * {
@ -67,7 +73,7 @@ on the court.
}
.player:focus-within .player-selection-tab {
visibility: visible;
display: flex;
}
.player:focus-within .player-piece {

@ -0,0 +1,23 @@
@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;
--player-allies-color: #64e4f5;
--player-opponents-color: #f59264;
--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;
}

@ -0,0 +1,11 @@
export interface Ball {
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
bottom_percentage: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
right_percentage: number
}

@ -1,6 +1,7 @@
import { Team } from "./Team"
export interface Player {
id: string
/**
* the player's team
* */
@ -20,4 +21,6 @@ export interface Player {
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
rightRatio: number
hasBall: boolean
}

@ -10,9 +10,13 @@ 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"
import { BallPiece } from "../components/editor/BallPiece"
import { Player } from "../tactic/Player"
import { Tactic, TacticContent } from "../tactic/Tactic"
import { fetchAPI } from "../Fetcher"
@ -27,10 +31,21 @@ const ERROR_STYLE: CSSProperties = {
borderColor: "red",
}
const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export interface EditorViewProps {
tactic: Tactic
onContentChange: (tactic: TacticContent) => Promise<boolean>
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"
}
/**
@ -41,41 +56,65 @@ interface RackedPlayer {
key: string
}
export default function Editor({
id,
name,
content,
}: {
id: number
name: string
content: string
}) {
export default function Editor({ id, name, courtType, content }: EditorProps) {
const isInGuestMode = id == -1
const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
const editorContent =
isInGuestMode && storage_content != null ? storage_content : content
const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY)
const editorName =
isInGuestMode && storage_name != null ? storage_name : name
return (
<EditorView
tactic={{ name, id, content: JSON.parse(content) }}
onContentChange={(content: TacticContent) =>
fetchAPI(`tactic/${id}/save`, { content }).then((r) => r.ok)
tactic={{
name: editorName,
id,
content: JSON.parse(editorContent),
}}
onContentChange={async (content: TacticContent) => {
if (isInGuestMode) {
localStorage.setItem(
GUEST_MODE_CONTENT_STORAGE_KEY,
JSON.stringify(content),
)
return SaveStates.Guest
}
onNameChange={(name: string) =>
fetchAPI(`tactic/${id}/edit/name`, { name }).then((r) => r.ok)
return fetchAPI(`tactic/${id}/save`, { content }).then((r) =>
r.ok ? SaveStates.Ok : SaveStates.Err,
)
}}
onNameChange={async (name: string) => {
if (isInGuestMode) {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}
return fetchAPI(`tactic/${id}/edit/name`, { name }).then(
(r) => r.ok,
)
}}
courtType={courtType}
/>
)
}
function EditorView({
tactic: { name, content: initialContent },
tactic: { id, name, content: initialContent },
onContentChange,
onNameChange,
courtType,
}: EditorViewProps) {
const [style, setStyle] = useState<CSSProperties>({})
const isInGuestMode = id == -1
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [content, setContent, saveState] = useContentState(
initialContent,
(content) =>
onContentChange(content).then((success) =>
success ? SaveStates.Ok : SaveStates.Err,
),
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
onContentChange,
)
const [allies, setAllies] = useState(
getRackPlayers(Team.Allies, content.players),
)
@ -83,6 +122,12 @@ function EditorView({
getRackPlayers(Team.Opponents, content.players),
)
const [showBall, setShowBall] = useState(
content.players.find((p) => p.hasBall) == undefined,
)
const ballPiece = useRef<HTMLDivElement>(null)
const courtDivContentRef = useRef<HTMLDivElement>(null)
const canDetach = (ref: HTMLDivElement) => {
@ -109,35 +154,65 @@ function EditorView({
players: [
...content.players,
{
id: "player-" + content.players.length,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
hasBall: false,
},
],
}
})
}
const onBallDrop = (ref: HTMLDivElement) => {
const ballBounds = ref.getBoundingClientRect()
let ballAssigned = false
setContent((content) => {
const players = content.players.map((player) => {
if (ballAssigned) {
return { ...player, hasBall: false }
}
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
const doesOverlap = !(
ballBounds.top > playerBounds.bottom ||
ballBounds.right < playerBounds.left ||
ballBounds.bottom < playerBounds.top ||
ballBounds.left > playerBounds.right
)
if (doesOverlap) {
ballAssigned = true
}
return { ...player, hasBall: doesOverlap }
})
setShowBall(!ballAssigned)
return { players: players }
})
}
return (
<div id="main-div">
<div id="topbar-div">
<div id="topbar-left">
LEFT
<SavingState state={saveState} />
</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)
})
}}
/>
</div>
<div id="topbar-right">RIGHT</div>
<div id="topbar-right"></div>
</div>
<div id="edit-div">
<div id="racks">
@ -148,9 +223,22 @@ function EditorView({
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/>
{showBall && (
<BallPiece
onDrop={() => onBallDrop(ballPiece.current!)}
pieceRef={ballPiece}
/>
)}
<Rack
id="opponent-rack"
objects={opponents}
@ -158,14 +246,24 @@ function EditorView({
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/>
</div>
<div id="court-div">
<div id="court-div-bounds" ref={courtDivContentRef}>
<div id="court-div-bounds">
<BasketCourt
players={content.players}
onBallDrop={onBallDrop}
courtImage={
courtType == "PLAIN" ? plainCourt : halfCourt
}
courtRef={courtDivContentRef}
onPlayerChange={(player) => {
setContent((content) => ({
players: toSplicedPlayers(
@ -191,6 +289,9 @@ function EditorView({
case Team.Allies:
setter = setAllies
}
if (player.hasBall) {
setShowBall(true)
}
setter((players) => [
...players,
{
@ -220,12 +321,14 @@ function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] {
function useContentState<S>(
initialContent: S,
initialSaveState: SaveState,
saveStateCallback: (s: S) => Promise<SaveState>,
): [S, Dispatch<SetStateAction<S>>, SaveState] {
const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(SaveStates.Ok)
const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback((newState: SetStateAction<S>) => {
const setContentSynced = useCallback(
(newState: SetStateAction<S>) => {
setContent((content) => {
const state =
typeof newState === "function"
@ -239,7 +342,9 @@ function useContentState<S>(
}
return state
})
}, [saveStateCallback])
},
[saveStateCallback],
)
return [content, setContentSynced, savingState]
}

@ -0,0 +1,64 @@
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;
@ -84,11 +85,17 @@ function getRoutes(): AltoRouter {
$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)));
$ar->map("GET", "/tactic/new", Action::auth(fn(SessionHandle $s) => getEditorController()->createNew($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)));

@ -20,31 +20,32 @@ CREATE TABLE Tactic
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 Account (id)
);

@ -64,14 +64,6 @@ class AuthController {
*/
public function login(array $request, MutableSessionHandle $session): HttpResponse {
$fails = [];
$request = HttpRequest::from($request, $fails, [
"password" => [Validators::lenBetween(6, 256)],
"email" => [Validators::email(), Validators::lenBetween(5, 256)],
]);
if (!empty($fails)) {
return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails]);
}
$account = $this->model->login($request['email'], $request['password'], $fails);
if (!empty($fails)) {
return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails]);

@ -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,16 +27,43 @@ 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(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" => '{"players": []}',
"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 createNew(SessionHandle $session): ViewHttpResponse {
$tactic = $this->model->makeNewDefault($session->getAccount()->getId());
public function createNewOfKind(CourtType $type, SessionHandle $session): ViewHttpResponse {
$action = $session->getAccount();
if ($action == null) {
return $this->openTestEditor($type);
}
$tactic = $this->model->makeNewDefault($session->getAccount()->getId(), $type);
return $this->openEditorFor($tactic);
}
@ -55,6 +84,4 @@ class EditorController {
return $this->openEditorFor($tactic);
}
}

@ -2,8 +2,10 @@
namespace IQBall\App\Controller;
use IQBall\App\Session\MutableSessionHandle;
use IQBall\App\Session\SessionHandle;
use IQBall\App\ViewHttpResponse;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Model\TacticModel;
class UserController {
@ -33,4 +35,9 @@ class UserController {
return ViewHttpResponse::twig("account_settings.twig", []);
}
public function disconnect(MutableSessionHandle $session): HttpResponse {
$session->destroy();
return HttpResponse::redirect("/");
}
}

@ -20,7 +20,7 @@ class VisualizerController {
}
/**
* opens a visualisation page for the tactic specified by its identifier in the url.
* Opens a visualisation page for the tactic specified by its identifier in the url.
* @param int $id
* @param SessionHandle $session
* @return HttpResponse

@ -17,4 +17,6 @@ interface MutableSessionHandle extends SessionHandle {
* @param Account $account update the session's account
*/
public function setAccount(Account $account): void;
public function destroy(): void;
}

@ -31,4 +31,8 @@ class PhpSessionHandle implements MutableSessionHandle {
public function setInitialTarget(?string $url): void {
$_SESSION["target"] = $url;
}
public function destroy(): void {
session_destroy();
}
}

@ -40,18 +40,6 @@
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
.error-messages {
color: #ff331a;
@ -68,6 +56,31 @@
;
}
{% endfor %}
.inscr {
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;
}
</style>
<div class="container">
@ -76,21 +89,19 @@
<div class="form-group">
{% for name in fails %}
<label class="error-messages"> {{ name.getFieldName() }} : {{ name.getMessage() }} </label>
<label class="error-messages"> {{ name.getMessage() }} </label>
{% endfor %}
<label for="email">Email :</label>
<input type="text" id="email" name="email" required>
<label for="password">Mot de passe :</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<input type="submit" value="S'identifier">
<a href="{{ path('/register') }}" class="inscr">Vous n'avez pas de compte ?</a>
<br><br>
<div id = "buttons">
<input class = "button" type="submit" value="Se connecter">
</div>
</form>
</div>
</body>
</html>

@ -40,23 +40,11 @@
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.error-messages {
color: #ff331a;
font-style: italic;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
{% for err in fails %}
.form-group
@ -69,6 +57,31 @@
}
{% endfor %}
.inscr{
font-size: small;
text-align: right;
}
#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;
}
</style>
@ -87,12 +100,13 @@
<input type="password" id="password" name="password" required>
<label for="confirmpassword">Confirmer le mot de passe :</label>
<input type="password" id="confirmpassword" name="confirmpassword" required>
<label for="email">Email :</label>
<input type="text" id="email" name="email" required>
<a href="{{ path('/login') }}" class="inscr">Vous avez déjà un compte ?</a>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
<div id = "buttons">
<input class = "button" type="submit" value="Créer votre compte">
</div>
</form>
</div>

@ -50,6 +50,7 @@
</head>
<body>
<button onclick="location.pathname='{{ path('/disconnect') }}'"> Se déconnecter</button>
<div id="bandeau">
<h1>IQ Ball</h1>
<div id="account" onclick="location.pathname='{{ path('/settings') }}'">

@ -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());
@ -99,5 +103,4 @@ class TacticInfoGateway {
]);
return $stmnt->rowCount() == 1;
}
}

@ -63,16 +63,10 @@ class AuthModel {
*/
public function login(string $email, string $password, array &$failures): ?Account {
$hash = $this->gateway->getHash($email);
if ($hash == null) {
$failures[] = new FieldValidationFail("email", "l'addresse email n'est pas connue.");
if ($hash == null or (!password_verify($password, $hash))) {
$failures[] = new ValidationFail("email","Adresse email ou mot de passe invalide");
return null;
}
if (!password_verify($password, $hash)) {
$failures[] = new FieldValidationFail("password", "Mot de passe invalide.");
return null;
}
return $this->gateway->getAccountFromMail($email);
}

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