Compare commits
4 Commits
Author | SHA1 | Date |
---|---|---|
|
56b6b4351c | 1 year ago |
|
c82b329652 | 1 year ago |
|
6875c500f2 | 1 year ago |
|
e259d64387 | 1 year ago |
@ -1,2 +1,2 @@
|
|||||||
VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master
|
VITE_API_ENDPOINT=/api
|
||||||
#VITE_API_ENDPOINT=http://localhost:5254
|
VITE_BASE=
|
@ -1,25 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2021: true },
|
|
||||||
extends: [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:react-hooks/recommended",
|
|
||||||
"plugin:react/recommended",
|
|
||||||
"plugin:react/jsx-runtime",
|
|
||||||
],
|
|
||||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
plugins: ["react-refresh"],
|
|
||||||
rules: {
|
|
||||||
"react-refresh/only-export-components": [
|
|
||||||
"warn",
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: "detect",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
@ -1,28 +1,44 @@
|
|||||||
# Logs
|
.vs
|
||||||
logs
|
.vscode
|
||||||
*.log
|
.idea
|
||||||
npm-debug.log*
|
.code
|
||||||
yarn-debug.log*
|
.vite
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
vendor
|
||||||
dist
|
.nfs*
|
||||||
dist-ssr
|
composer.lock
|
||||||
*.local
|
*.phar
|
||||||
|
/dist
|
||||||
|
.guard
|
||||||
|
|
||||||
# Editor directories and files
|
# sqlite database files
|
||||||
.vscode/*
|
*.sqlite
|
||||||
.idea
|
|
||||||
.DS_Store
|
views-mappings.php
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
stats.html
|
.php-cs-fixer.cache
|
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$finder = (new PhpCsFixer\Finder())->in(__DIR__);
|
||||||
|
|
||||||
|
return (new PhpCsFixer\Config())
|
||||||
|
->setRules([
|
||||||
|
'@PER-CS' => true,
|
||||||
|
'@PHP74Migration' => true,
|
||||||
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
|
'braces_position' => [
|
||||||
|
'classes_opening_brace' => 'same_line',
|
||||||
|
'functions_opening_brace' => 'same_line'
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->setIndent(" ")
|
||||||
|
->setFinder($finder);
|
@ -1 +0,0 @@
|
|||||||
master
|
|
@ -1,3 +1,3 @@
|
|||||||
- [Description.md](Description.md)
|
# The wiki also exists
|
||||||
- [Conception.md](Conception.md)
|
|
||||||
- [how-to-dev.md](how-to-dev.md)
|
Some of our explanation are contained in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki)
|
@ -1,8 +1,7 @@
|
|||||||
# IQBall - Web Application
|
# IQBall - Web Application
|
||||||
|
|
||||||
This repository hosts the IQBall application for web
|
This repository hosts the IQBall application for web
|
||||||
|
|
||||||
## Read the docs !
|
## Read the docs !
|
||||||
|
|
||||||
You can find some additional documentation in the [Documentation](Documentation) folder,
|
You can find some additional documentation in the [Documentation](Documentation) folder,
|
||||||
and in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki).
|
and in the [wiki](https://codefirst.iut.uca.fr/git/IQBall/Application-Web/wiki).
|
||||||
|
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -xeu
|
|
||||||
|
|
||||||
export OUTPUT=$1
|
|
||||||
export BASE=$2
|
|
||||||
|
|
||||||
rm -rf "$OUTPUT"/*
|
|
||||||
|
|
||||||
echo "VITE_API_ENDPOINT=$BASE/api" >> .env.PROD
|
|
||||||
echo "VITE_BASE=$BASE" >> .env.PROD
|
|
||||||
|
|
||||||
ci/build_react.msh
|
|
||||||
|
|
||||||
mkdir -p "$OUTPUT"/profiles/
|
|
||||||
|
|
||||||
sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > "$OUTPUT"/config.php
|
|
||||||
sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php
|
|
||||||
|
|
||||||
cp -r vendor sql src public "$OUTPUT"
|
|
@ -1,11 +1,11 @@
|
|||||||
set -eu
|
set -e
|
||||||
|
|
||||||
mkdir ~/.ssh
|
mkdir ~/.ssh
|
||||||
echo "$SERVER_PRIVATE_KEY" > ~/.ssh/id_rsa
|
echo "$SERVER_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||||
chmod 0600 ~/.ssh
|
chmod 0600 ~/.ssh
|
||||||
chmod 0500 ~/.ssh/id_rsa*
|
chmod 0500 ~/.ssh/id_rsa*
|
||||||
|
|
||||||
SERVER_ROOT=/srv/www/iqball
|
SERVER_ROOT=/srv/www/IQBall
|
||||||
|
|
||||||
ssh -p 80 -o 'StrictHostKeyChecking=no' iqball@maxou.dev mkdir -p $SERVER_ROOT/$DRONE_BRANCH
|
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
|
rsync -avz -e "ssh -p 80 -o 'StrictHostKeyChecking=no'" --delete /outputs/* iqball@maxou.dev:$SERVER_ROOT/$DRONE_BRANCH
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"IQBall\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"altorouter/altorouter": "1.2.0",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-pdo_sqlite": "*",
|
||||||
|
"twig/twig":"^2.0",
|
||||||
|
"phpstan/phpstan": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.38"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// `dev-config-profile.php` by default.
|
||||||
|
// on production server the included profile is `prod-config-profile.php`.
|
||||||
|
// Please do not touch.
|
||||||
|
require /*PROFILE_FILE*/ "profiles/dev-config-profile.php";
|
||||||
|
|
||||||
|
const SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the given relative source uri (relative to the `/front` folder) to its actual location depending on imported profile.
|
||||||
|
* @param string $assetURI relative uri path from `/front` folder
|
||||||
|
* @return string valid url that points to the given uri
|
||||||
|
|
||||||
|
*/
|
||||||
|
function asset(string $assetURI): string {
|
||||||
|
return _asset($assetURI);
|
||||||
|
}
|
||||||
|
|
||||||
|
global $_data_source_name;
|
||||||
|
$data_source_name = $_data_source_name;
|
||||||
|
const DATABASE_USER = _DATABASE_USER;
|
||||||
|
const DATABASE_PASSWORD = _DATABASE_PASSWORD;
|
@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
## verify php and typescript types
|
||||||
|
|
||||||
|
echo "formatting php typechecking"
|
||||||
|
vendor/bin/php-cs-fixer fix
|
||||||
|
|
||||||
|
echo "formatting typescript typechecking"
|
||||||
|
npm run format
|
@ -0,0 +1,16 @@
|
|||||||
|
import { API } from "./Constants"
|
||||||
|
|
||||||
|
export function fetchAPI(
|
||||||
|
url: string,
|
||||||
|
payload: unknown,
|
||||||
|
method = "POST",
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`${API}/${url}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
import ReactDOM from "react-dom/client"
|
||||||
|
import React, { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically renders a React component, with given arguments
|
||||||
|
* @param Component the react component to render
|
||||||
|
* @param args the arguments to pass to the react component.
|
||||||
|
*/
|
||||||
|
export function renderView(Component: FunctionComponent, args: {}) {
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById("root") as HTMLElement,
|
||||||
|
)
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Component {...args} />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
}
|
Before Width: | Height: | Size: 747 B After Width: | Height: | Size: 747 B |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 6.0 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 301 B |
After Width: | Height: | Size: 507 B |
Before Width: | Height: | Size: 732 B After Width: | Height: | Size: 732 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 405 B |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
@ -0,0 +1,27 @@
|
|||||||
|
import {ReactNode, useState} from "react";
|
||||||
|
import "../style/popup.css"
|
||||||
|
|
||||||
|
export interface PopupProps {
|
||||||
|
children: ReactNode[] | ReactNode,
|
||||||
|
displayState: boolean,
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Popup({children, displayState, onClose}: PopupProps) {
|
||||||
|
return (
|
||||||
|
<div id="popup-background"
|
||||||
|
style={{
|
||||||
|
display: displayState ? 'flex' : 'none',
|
||||||
|
position: "absolute",
|
||||||
|
width: "78%",
|
||||||
|
height: "79%",
|
||||||
|
overflow:"hidden"
|
||||||
|
}}
|
||||||
|
onClick={onClose}>
|
||||||
|
<div id="content" onClick={event => event.stopPropagation()}>
|
||||||
|
<button id="close-button" onClick={onClose}>X</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,19 +1,15 @@
|
|||||||
import { BallPiece } from "../editor/BallPiece"
|
import { BallPiece } from "../editor/BallPiece"
|
||||||
import Draggable from "react-draggable"
|
import Draggable from "react-draggable"
|
||||||
import { useRef } from "react"
|
import { useRef } from "react"
|
||||||
import { NULL_POS } from "../../geo/Pos"
|
|
||||||
|
|
||||||
export interface BallActionProps {
|
export interface BallActionProps {
|
||||||
onDrop: (el: DOMRect) => void
|
onDrop: (el: HTMLElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BallAction({ onDrop }: BallActionProps) {
|
export default function BallAction({ onDrop }: BallActionProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
return (
|
return (
|
||||||
<Draggable
|
<Draggable onStop={() => onDrop(ref.current!)} nodeRef={ref}>
|
||||||
nodeRef={ref}
|
|
||||||
onStop={() => onDrop(ref.current!.getBoundingClientRect())}
|
|
||||||
position={NULL_POS}>
|
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<BallPiece />
|
<BallPiece />
|
||||||
</div>
|
</div>
|
@ -1,8 +1,7 @@
|
|||||||
import "../../style/ball.css"
|
import "../../style/ball.css"
|
||||||
|
|
||||||
import BallSvg from "../../assets/icon/ball.svg?react"
|
import BallSvg from "../../assets/icon/ball.svg?react"
|
||||||
import { BALL_ID } from "../../model/tactic/CourtObjects"
|
|
||||||
|
|
||||||
export function BallPiece() {
|
export function BallPiece() {
|
||||||
return <BallSvg id={BALL_ID} className={"ball"} />
|
return <BallSvg className={"ball"} />
|
||||||
}
|
}
|
@ -0,0 +1,272 @@
|
|||||||
|
import { CourtBall } from "./CourtBall"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ReactElement,
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import CourtPlayer from "./CourtPlayer"
|
||||||
|
|
||||||
|
import { Player } from "../../model/tactic/Player"
|
||||||
|
import { Action, ActionKind } from "../../model/tactic/Action"
|
||||||
|
import ArrowAction from "../actions/ArrowAction"
|
||||||
|
import { middlePos, ratioWithinBase } from "../arrows/Pos"
|
||||||
|
import BallAction from "../actions/BallAction"
|
||||||
|
import { CourtObject } from "../../model/tactic/Ball"
|
||||||
|
import { contains } from "../arrows/Box"
|
||||||
|
import { CourtAction } from "../../views/editor/CourtAction"
|
||||||
|
|
||||||
|
export interface BasketCourtProps {
|
||||||
|
players: Player[]
|
||||||
|
actions: Action[]
|
||||||
|
objects: CourtObject[]
|
||||||
|
|
||||||
|
renderAction: (a: Action, key: number) => ReactElement
|
||||||
|
setActions: (f: (a: Action[]) => Action[]) => void
|
||||||
|
|
||||||
|
onPlayerRemove: (p: Player) => void
|
||||||
|
onPlayerChange: (p: Player) => void
|
||||||
|
|
||||||
|
onBallRemove: () => void
|
||||||
|
onBallMoved: (ball: DOMRect) => void
|
||||||
|
|
||||||
|
courtImage: ReactElement
|
||||||
|
courtRef: RefObject<HTMLDivElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BasketCourt({
|
||||||
|
players,
|
||||||
|
actions,
|
||||||
|
objects,
|
||||||
|
renderAction,
|
||||||
|
setActions,
|
||||||
|
onPlayerRemove,
|
||||||
|
onPlayerChange,
|
||||||
|
|
||||||
|
onBallMoved,
|
||||||
|
onBallRemove,
|
||||||
|
|
||||||
|
courtImage,
|
||||||
|
courtRef,
|
||||||
|
}: BasketCourtProps) {
|
||||||
|
function placeArrow(origin: Player, arrowHead: DOMRect) {
|
||||||
|
const originRef = document.getElementById(origin.id)!
|
||||||
|
const courtBounds = courtRef.current!.getBoundingClientRect()
|
||||||
|
const start = ratioWithinBase(
|
||||||
|
middlePos(originRef.getBoundingClientRect()),
|
||||||
|
courtBounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
if (player.id == origin.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerBounds = document
|
||||||
|
.getElementById(player.id)!
|
||||||
|
.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
playerBounds.top > arrowHead.bottom ||
|
||||||
|
playerBounds.right < arrowHead.left ||
|
||||||
|
playerBounds.bottom < arrowHead.top ||
|
||||||
|
playerBounds.left > arrowHead.right
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const targetPos = document
|
||||||
|
.getElementById(player.id)!
|
||||||
|
.getBoundingClientRect()
|
||||||
|
|
||||||
|
const end = ratioWithinBase(middlePos(targetPos), courtBounds)
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
fromPlayerId: originRef.id,
|
||||||
|
toPlayerId: player.id,
|
||||||
|
type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN,
|
||||||
|
moveFrom: start,
|
||||||
|
segments: [{ next: end }],
|
||||||
|
}
|
||||||
|
setActions((actions) => [...actions, action])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
fromPlayerId: originRef.id,
|
||||||
|
type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE,
|
||||||
|
moveFrom: ratioWithinBase(
|
||||||
|
middlePos(originRef.getBoundingClientRect()),
|
||||||
|
courtBounds,
|
||||||
|
),
|
||||||
|
segments: [
|
||||||
|
{ next: ratioWithinBase(middlePos(arrowHead), courtBounds) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
setActions((actions) => [...actions, action])
|
||||||
|
}
|
||||||
|
|
||||||
|
const [previewAction, setPreviewAction] = useState<Action | null>(null)
|
||||||
|
|
||||||
|
const updateActionsRelatedTo = useCallback((player: Player) => {
|
||||||
|
const newPos = ratioWithinBase(
|
||||||
|
middlePos(
|
||||||
|
document.getElementById(player.id)!.getBoundingClientRect(),
|
||||||
|
),
|
||||||
|
courtRef.current!.getBoundingClientRect(),
|
||||||
|
)
|
||||||
|
setActions((actions) =>
|
||||||
|
actions.map((a) => {
|
||||||
|
if (a.fromPlayerId == player.id) {
|
||||||
|
return { ...a, moveFrom: newPos }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.toPlayerId == player.id) {
|
||||||
|
const segments = a.segments.toSpliced(
|
||||||
|
a.segments.length - 1,
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
...a.segments[a.segments.length - 1],
|
||||||
|
next: newPos,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return { ...a, segments }
|
||||||
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [internActions, setInternActions] = useState<Action[]>([])
|
||||||
|
|
||||||
|
useLayoutEffect(() => setInternActions(actions), [actions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="court-container"
|
||||||
|
ref={courtRef}
|
||||||
|
style={{ position: "relative" }}>
|
||||||
|
{courtImage}
|
||||||
|
|
||||||
|
{players.map((player) => (
|
||||||
|
<CourtPlayer
|
||||||
|
key={player.id}
|
||||||
|
player={player}
|
||||||
|
onDrag={() => updateActionsRelatedTo(player)}
|
||||||
|
onChange={onPlayerChange}
|
||||||
|
onRemove={() => onPlayerRemove(player)}
|
||||||
|
courtRef={courtRef}
|
||||||
|
availableActions={(pieceRef) => [
|
||||||
|
<ArrowAction
|
||||||
|
key={1}
|
||||||
|
onHeadMoved={(headPos) => {
|
||||||
|
const baseBounds =
|
||||||
|
courtRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
const arrowHeadPos = middlePos(headPos)
|
||||||
|
|
||||||
|
const target = players.find(
|
||||||
|
(p) =>
|
||||||
|
p != player &&
|
||||||
|
contains(
|
||||||
|
document
|
||||||
|
.getElementById(p.id)!
|
||||||
|
.getBoundingClientRect(),
|
||||||
|
arrowHeadPos,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
setPreviewAction((action) => ({
|
||||||
|
...action!,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
next: ratioWithinBase(
|
||||||
|
arrowHeadPos,
|
||||||
|
baseBounds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: player.hasBall
|
||||||
|
? target
|
||||||
|
? ActionKind.SHOOT
|
||||||
|
: ActionKind.DRIBBLE
|
||||||
|
: target
|
||||||
|
? ActionKind.SCREEN
|
||||||
|
: ActionKind.MOVE,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onHeadPicked={(headPos) => {
|
||||||
|
;(document.activeElement as HTMLElement).blur()
|
||||||
|
const baseBounds =
|
||||||
|
courtRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
setPreviewAction({
|
||||||
|
type: player.hasBall
|
||||||
|
? ActionKind.DRIBBLE
|
||||||
|
: ActionKind.MOVE,
|
||||||
|
fromPlayerId: player.id,
|
||||||
|
toPlayerId: undefined,
|
||||||
|
moveFrom: ratioWithinBase(
|
||||||
|
middlePos(
|
||||||
|
pieceRef.getBoundingClientRect(),
|
||||||
|
),
|
||||||
|
baseBounds,
|
||||||
|
),
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
next: ratioWithinBase(
|
||||||
|
middlePos(headPos),
|
||||||
|
baseBounds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onHeadDropped={(headRect) => {
|
||||||
|
placeArrow(player, headRect)
|
||||||
|
setPreviewAction(null)
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
player.hasBall && (
|
||||||
|
<BallAction
|
||||||
|
key={2}
|
||||||
|
onDrop={(ref) =>
|
||||||
|
onBallMoved(ref.getBoundingClientRect())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{internActions.map((action, idx) => renderAction(action, idx))}
|
||||||
|
|
||||||
|
{objects.map((object) => {
|
||||||
|
if (object.type == "ball") {
|
||||||
|
return (
|
||||||
|
<CourtBall
|
||||||
|
onMoved={onBallMoved}
|
||||||
|
ball={object}
|
||||||
|
onRemove={onBallRemove}
|
||||||
|
key="ball"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error("unknown court object" + object.type)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{previewAction && (
|
||||||
|
<CourtAction
|
||||||
|
courtRef={courtRef}
|
||||||
|
action={previewAction}
|
||||||
|
//do nothing on change, not really possible as it's a preview arrow
|
||||||
|
onActionDeleted={() => {}}
|
||||||
|
onActionChanges={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
import React, { useRef } from "react"
|
||||||
|
import Draggable from "react-draggable"
|
||||||
|
import { BallPiece } from "./BallPiece"
|
||||||
|
import { Ball } from "../../model/tactic/Ball"
|
||||||
|
|
||||||
|
export interface CourtBallProps {
|
||||||
|
onMoved: (rect: DOMRect) => void
|
||||||
|
onRemove: () => void
|
||||||
|
ball: Ball
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
|
||||||
|
const pieceRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const x = ball.rightRatio
|
||||||
|
const y = ball.bottomRatio
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable
|
||||||
|
onStop={() => onMoved(pieceRef.current!.getBoundingClientRect())}
|
||||||
|
nodeRef={pieceRef}>
|
||||||
|
<div
|
||||||
|
className={"ball-div"}
|
||||||
|
ref={pieceRef}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key == "Delete") onRemove()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${x * 100}%`,
|
||||||
|
top: `${y * 100}%`,
|
||||||
|
}}>
|
||||||
|
<BallPiece />
|
||||||
|
</div>
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
import { ReactNode, RefObject, useRef } from "react"
|
||||||
|
import "../../style/player.css"
|
||||||
|
import Draggable from "react-draggable"
|
||||||
|
import { PlayerPiece } from "./PlayerPiece"
|
||||||
|
import { Player } from "../../model/tactic/Player"
|
||||||
|
import { NULL_POS, ratioWithinBase } from "../arrows/Pos"
|
||||||
|
|
||||||
|
export interface PlayerProps {
|
||||||
|
player: Player
|
||||||
|
onDrag: () => void
|
||||||
|
onChange: (p: Player) => void
|
||||||
|
onRemove: () => void
|
||||||
|
courtRef: RefObject<HTMLElement>
|
||||||
|
availableActions: (ro: HTMLElement) => ReactNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player that is placed on the court, which can be selected, and moved in the associated bounds
|
||||||
|
* */
|
||||||
|
export default function CourtPlayer({
|
||||||
|
player,
|
||||||
|
onDrag,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
courtRef,
|
||||||
|
availableActions,
|
||||||
|
}: PlayerProps) {
|
||||||
|
const hasBall = player.hasBall
|
||||||
|
const x = player.rightRatio
|
||||||
|
const y = player.bottomRatio
|
||||||
|
const pieceRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable
|
||||||
|
handle=".player-piece"
|
||||||
|
nodeRef={pieceRef}
|
||||||
|
onDrag={onDrag}
|
||||||
|
//The piece is positioned using top/bottom style attributes instead
|
||||||
|
position={NULL_POS}
|
||||||
|
onStop={() => {
|
||||||
|
const pieceBounds = pieceRef.current!.getBoundingClientRect()
|
||||||
|
const parentBounds = courtRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
const { x, y } = ratioWithinBase(pieceBounds, parentBounds)
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
id: player.id,
|
||||||
|
rightRatio: x,
|
||||||
|
bottomRatio: y,
|
||||||
|
team: player.team,
|
||||||
|
role: player.role,
|
||||||
|
hasBall: player.hasBall,
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
id={player.id}
|
||||||
|
ref={pieceRef}
|
||||||
|
className="player"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${x * 100}%`,
|
||||||
|
top: `${y * 100}%`,
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
className="player-content"
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key == "Delete") onRemove()
|
||||||
|
}}>
|
||||||
|
<div className="player-actions">
|
||||||
|
{availableActions(pieceRef.current!)}
|
||||||
|
</div>
|
||||||
|
<PlayerPiece
|
||||||
|
team={player.team}
|
||||||
|
text={player.role}
|
||||||
|
hasBall={hasBall}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
import { Pos } from "../../components/arrows/Pos"
|
||||||
|
import { Segment } from "../../components/arrows/BendableArrow"
|
||||||
|
import { PlayerId } from "./Player"
|
||||||
|
|
||||||
|
export enum ActionKind {
|
||||||
|
SCREEN = "SCREEN",
|
||||||
|
DRIBBLE = "DRIBBLE",
|
||||||
|
MOVE = "MOVE",
|
||||||
|
SHOOT = "SHOOT",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action = { type: ActionKind } & MovementAction
|
||||||
|
|
||||||
|
export interface MovementAction {
|
||||||
|
fromPlayerId: PlayerId
|
||||||
|
toPlayerId?: PlayerId
|
||||||
|
moveFrom: Pos
|
||||||
|
segments: Segment[]
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
export type CourtObject = { type: "ball" } & Ball
|
||||||
|
|
||||||
|
export interface Ball {
|
||||||
|
/**
|
||||||
|
* The ball is a "ball" court object
|
||||||
|
*/
|
||||||
|
readonly type: "ball"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
|
||||||
|
*/
|
||||||
|
readonly bottomRatio: number
|
||||||
|
/**
|
||||||
|
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
|
||||||
|
*/
|
||||||
|
readonly rightRatio: number
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
export type PlayerId = string
|
||||||
|
|
||||||
|
export enum PlayerTeam {
|
||||||
|
Allies = "allies",
|
||||||
|
Opponents = "opponents",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
readonly id: PlayerId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the player's team
|
||||||
|
* */
|
||||||
|
readonly team: PlayerTeam
|
||||||
|
|
||||||
|
/**
|
||||||
|
* player's role
|
||||||
|
* */
|
||||||
|
readonly role: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
|
||||||
|
*/
|
||||||
|
readonly bottomRatio: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
|
||||||
|
*/
|
||||||
|
readonly rightRatio: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the player has a basketball
|
||||||
|
*/
|
||||||
|
readonly hasBall: boolean
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
import { Player } from "./Player"
|
||||||
|
import { CourtObject } from "./Ball"
|
||||||
|
import { Action } from "./Action"
|
||||||
|
|
||||||
|
export interface Tactic {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
content: TacticContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TacticContent {
|
||||||
|
players: Player[]
|
||||||
|
objects: CourtObject[]
|
||||||
|
actions: Action[]
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
@import url(../theme/dark.css);
|
||||||
|
@import url(personnal_space.css);
|
||||||
|
@import url(side_menu.css);
|
||||||
|
@import url(../template/header.css);
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* background-color: #303030; */
|
||||||
|
}
|
||||||
|
#main {
|
||||||
|
/* margin-left : 10%;
|
||||||
|
margin-right: 10%; */
|
||||||
|
/* border : solid 1px #303030; */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--font-content);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin: 0px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--second-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data {
|
||||||
|
border: 1.5px solid var(--main-contrast-color);
|
||||||
|
background-color: var(--main-color);
|
||||||
|
border-radius: 0.75cap;
|
||||||
|
color: var(--main-contrast-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-button {
|
||||||
|
width: 80%;
|
||||||
|
margin-left: 5%;
|
||||||
|
margin-top: 5%;
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
@import url(theme/dark.css);
|
||||||
|
|
||||||
|
#popup-background{
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
color: white;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#content{
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: var(--third-color);
|
||||||
|
display:flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#close-button{
|
||||||
|
border-radius: 100px;
|
||||||
|
align-self: end;
|
||||||
|
}
|
@ -1,62 +1,65 @@
|
|||||||
@import url(../theme/default.css);
|
|
||||||
|
|
||||||
#header {
|
#header {
|
||||||
user-select: none;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background-color: var(--home-main-color);
|
background-color: var(--main-color);
|
||||||
margin: 0;
|
margin: 0px;
|
||||||
|
/* border : var(--accent-color) 1px solid; */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
font-family: var(--font-title);
|
font-family: var(--font-title);
|
||||||
|
/* border-radius: 0.75cap; */
|
||||||
}
|
}
|
||||||
|
|
||||||
#img-account {
|
#img-account {
|
||||||
|
width: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 5px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 20%;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#header-left,
|
|
||||||
#header-right,
|
#header-right,
|
||||||
#header-center {
|
#header-left {
|
||||||
width: 100%;
|
width: 10%;
|
||||||
|
/* border: yellow 2px solid; */
|
||||||
}
|
}
|
||||||
|
|
||||||
#header-right {
|
#header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: end;
|
align-items: center;
|
||||||
margin-right: 30px;
|
}
|
||||||
color: white;
|
|
||||||
|
#username {
|
||||||
|
color: var(--main-contrast-color);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#clickable-header-right:hover #username {
|
#clickable-header-right:hover #username {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#header-center {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
#clickable-header-right {
|
#clickable-header-right {
|
||||||
|
width: 40%;
|
||||||
border-radius: 1cap;
|
border-radius: 1cap;
|
||||||
|
padding: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
display: flex;
|
#clickable-header-right:hover {
|
||||||
align-items: center;
|
border: orange 1px solid;
|
||||||
justify-content: space-evenly;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#img-account {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#iqball {
|
#iqball {
|
||||||
margin: 0;
|
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 35px;
|
font-size: 45px;
|
||||||
}
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
:root {
|
||||||
|
--main-color: #191a21;
|
||||||
|
--second-color: #282a36;
|
||||||
|
--third-color: #303341;
|
||||||
|
--accent-color: #ffa238;
|
||||||
|
--main-contrast-color: #e6edf3;
|
||||||
|
--font-title: Helvetica;
|
||||||
|
--font-content: Helvetica;
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
#main {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--main-color);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#court-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--main-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#court {
|
||||||
|
max-width: 80%;
|
||||||
|
max-height: 80%;
|
||||||
|
}
|
@ -0,0 +1,624 @@
|
|||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import "../style/editor.css"
|
||||||
|
import TitleInput from "../components/TitleInput"
|
||||||
|
import PlainCourt from "../assets/court/full_court.svg?react"
|
||||||
|
import HalfCourt from "../assets/court/half_court.svg?react"
|
||||||
|
|
||||||
|
import { BallPiece } from "../components/editor/BallPiece"
|
||||||
|
|
||||||
|
import { Rack } from "../components/Rack"
|
||||||
|
import { PlayerPiece } from "../components/editor/PlayerPiece"
|
||||||
|
import { Player } from "../model/tactic/Player"
|
||||||
|
|
||||||
|
import { Tactic, TacticContent } from "../model/tactic/Tactic"
|
||||||
|
import { fetchAPI } from "../Fetcher"
|
||||||
|
import { PlayerTeam } from "../model/tactic/Player"
|
||||||
|
|
||||||
|
import SavingState, {
|
||||||
|
SaveState,
|
||||||
|
SaveStates,
|
||||||
|
} from "../components/editor/SavingState"
|
||||||
|
|
||||||
|
import { CourtObject } from "../model/tactic/Ball"
|
||||||
|
import { CourtAction } from "./editor/CourtAction"
|
||||||
|
import { BasketCourt } from "../components/editor/BasketCourt"
|
||||||
|
import { ratioWithinBase } from "../components/arrows/Pos"
|
||||||
|
import { Action, ActionKind } from "../model/tactic/Action"
|
||||||
|
import { BASE } from "../Constants"
|
||||||
|
|
||||||
|
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<SaveState>
|
||||||
|
onNameChange: (name: string) => Promise<boolean>
|
||||||
|
courtType: "PLAIN" | "HALF"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorProps {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
courtType: "PLAIN" | "HALF"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* information about a player that is into a rack
|
||||||
|
*/
|
||||||
|
interface RackedPlayer {
|
||||||
|
team: PlayerTeam
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RackedCourtObject = { key: "ball" }
|
||||||
|
|
||||||
|
export default function Editor({ id, name, courtType, content }: EditorProps) {
|
||||||
|
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: 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
|
||||||
|
}
|
||||||
|
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: { id, name, content: initialContent },
|
||||||
|
onContentChange,
|
||||||
|
onNameChange,
|
||||||
|
courtType,
|
||||||
|
}: EditorViewProps) {
|
||||||
|
const isInGuestMode = id == -1
|
||||||
|
|
||||||
|
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
|
||||||
|
const [content, setContent, saveState] = useContentState(
|
||||||
|
initialContent,
|
||||||
|
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
|
||||||
|
useMemo(
|
||||||
|
() =>
|
||||||
|
debounceAsync(
|
||||||
|
(content) =>
|
||||||
|
onContentChange(content).then((success) =>
|
||||||
|
success ? SaveStates.Ok : SaveStates.Err,
|
||||||
|
),
|
||||||
|
250,
|
||||||
|
),
|
||||||
|
[onContentChange],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [allies, setAllies] = useState(
|
||||||
|
getRackPlayers(PlayerTeam.Allies, content.players),
|
||||||
|
)
|
||||||
|
const [opponents, setOpponents] = useState(
|
||||||
|
getRackPlayers(PlayerTeam.Opponents, content.players),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [objects, setObjects] = useState<RackedCourtObject[]>(
|
||||||
|
isBallOnCourt(content) ? [] : [{ key: "ball" }],
|
||||||
|
)
|
||||||
|
|
||||||
|
const courtDivContentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const isBoundsOnCourt = (bounds: DOMRect) => {
|
||||||
|
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
// check if refBounds overlaps courtBounds
|
||||||
|
return !(
|
||||||
|
bounds.top > courtBounds.bottom ||
|
||||||
|
bounds.right < courtBounds.left ||
|
||||||
|
bounds.bottom < courtBounds.top ||
|
||||||
|
bounds.left > courtBounds.right
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => {
|
||||||
|
const refBounds = ref.getBoundingClientRect()
|
||||||
|
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
const { x, y } = ratioWithinBase(refBounds, courtBounds)
|
||||||
|
|
||||||
|
setContent((content) => {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
players: [
|
||||||
|
...content.players,
|
||||||
|
{
|
||||||
|
id: "player-" + element.key + "-" + element.team,
|
||||||
|
team: element.team,
|
||||||
|
role: element.key,
|
||||||
|
rightRatio: x,
|
||||||
|
bottomRatio: y,
|
||||||
|
hasBall: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: content.actions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onObjectDetach = (
|
||||||
|
ref: HTMLDivElement,
|
||||||
|
rackedObject: RackedCourtObject,
|
||||||
|
) => {
|
||||||
|
const refBounds = ref.getBoundingClientRect()
|
||||||
|
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
|
const { x, y } = ratioWithinBase(refBounds, courtBounds)
|
||||||
|
|
||||||
|
let courtObject: CourtObject
|
||||||
|
|
||||||
|
switch (rackedObject.key) {
|
||||||
|
case "ball":
|
||||||
|
const ballObj = content.objects.findIndex(
|
||||||
|
(o) => o.type == "ball",
|
||||||
|
)
|
||||||
|
const playerCollidedIdx = getPlayerCollided(
|
||||||
|
refBounds,
|
||||||
|
content.players,
|
||||||
|
)
|
||||||
|
if (playerCollidedIdx != -1) {
|
||||||
|
onBallDropOnPlayer(playerCollidedIdx)
|
||||||
|
setContent((content) => {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
objects: content.objects.toSpliced(ballObj, 1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
courtObject = {
|
||||||
|
type: "ball",
|
||||||
|
rightRatio: x,
|
||||||
|
bottomRatio: y,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error("unknown court object " + rackedObject.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent((content) => {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
objects: [...content.objects, courtObject],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlayerCollided = (
|
||||||
|
bounds: DOMRect,
|
||||||
|
players: Player[],
|
||||||
|
): number | -1 => {
|
||||||
|
for (let i = 0; i < players.length; i++) {
|
||||||
|
const player = players[i]
|
||||||
|
const playerBounds = document
|
||||||
|
.getElementById(player.id)!
|
||||||
|
.getBoundingClientRect()
|
||||||
|
const doesOverlap = !(
|
||||||
|
bounds.top > playerBounds.bottom ||
|
||||||
|
bounds.right < playerBounds.left ||
|
||||||
|
bounds.bottom < playerBounds.top ||
|
||||||
|
bounds.left > playerBounds.right
|
||||||
|
)
|
||||||
|
if (doesOverlap) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActions(actions: Action[], players: Player[]) {
|
||||||
|
return actions.map((action) => {
|
||||||
|
const originHasBall = players.find(
|
||||||
|
(p) => p.id == action.fromPlayerId,
|
||||||
|
)!.hasBall
|
||||||
|
|
||||||
|
let type = action.type
|
||||||
|
|
||||||
|
if (originHasBall && type == ActionKind.MOVE) {
|
||||||
|
type = ActionKind.DRIBBLE
|
||||||
|
} else if (originHasBall && type == ActionKind.SCREEN) {
|
||||||
|
type = ActionKind.SHOOT
|
||||||
|
} else if (type == ActionKind.DRIBBLE) {
|
||||||
|
type = ActionKind.MOVE
|
||||||
|
} else if (type == ActionKind.SHOOT) {
|
||||||
|
type = ActionKind.SCREEN
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...action,
|
||||||
|
type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBallDropOnPlayer = (playerCollidedIdx: number) => {
|
||||||
|
setContent((content) => {
|
||||||
|
const ballObj = content.objects.findIndex((o) => o.type == "ball")
|
||||||
|
let player = content.players.at(playerCollidedIdx) as Player
|
||||||
|
const players = content.players.toSpliced(playerCollidedIdx, 1, {
|
||||||
|
...player,
|
||||||
|
hasBall: true,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
actions: updateActions(content.actions, players),
|
||||||
|
players,
|
||||||
|
objects: content.objects.toSpliced(ballObj, 1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBallDrop = (refBounds: DOMRect) => {
|
||||||
|
if (!isBoundsOnCourt(refBounds)) {
|
||||||
|
removeCourtBall()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const playerCollidedIdx = getPlayerCollided(refBounds, content.players)
|
||||||
|
if (playerCollidedIdx != -1) {
|
||||||
|
setContent((content) => {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
players: content.players.map((player) => ({
|
||||||
|
...player,
|
||||||
|
hasBall: false,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBallDropOnPlayer(playerCollidedIdx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.objects.findIndex((o) => o.type == "ball") != -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
|
||||||
|
const { x, y } = ratioWithinBase(refBounds, courtBounds)
|
||||||
|
let courtObject: CourtObject
|
||||||
|
|
||||||
|
courtObject = {
|
||||||
|
type: "ball",
|
||||||
|
rightRatio: x,
|
||||||
|
bottomRatio: y,
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = content.players.map((player) => ({
|
||||||
|
...player,
|
||||||
|
hasBall: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setContent((content) => {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
actions: updateActions(content.actions, players),
|
||||||
|
players,
|
||||||
|
objects: [...content.objects, courtObject],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePlayer = (player: Player) => {
|
||||||
|
setContent((content) => ({
|
||||||
|
...content,
|
||||||
|
players: toSplicedPlayers(content.players, player, false),
|
||||||
|
objects: [...content.objects],
|
||||||
|
actions: content.actions.filter(
|
||||||
|
(a) =>
|
||||||
|
a.toPlayerId !== player.id && a.fromPlayerId !== player.id,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
let setter
|
||||||
|
switch (player.team) {
|
||||||
|
case PlayerTeam.Opponents:
|
||||||
|
setter = setOpponents
|
||||||
|
break
|
||||||
|
case PlayerTeam.Allies:
|
||||||
|
setter = setAllies
|
||||||
|
}
|
||||||
|
if (player.hasBall) {
|
||||||
|
setObjects([{ key: "ball" }])
|
||||||
|
}
|
||||||
|
setter((players) => [
|
||||||
|
...players,
|
||||||
|
{
|
||||||
|
team: player.team,
|
||||||
|
pos: player.role,
|
||||||
|
key: player.role,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCourtBall = () => {
|
||||||
|
setContent((content) => {
|
||||||
|
const ballObj = content.objects.findIndex((o) => o.type == "ball")
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
players: content.players.map((player) => ({
|
||||||
|
...player,
|
||||||
|
hasBall: false,
|
||||||
|
})),
|
||||||
|
objects: content.objects.toSpliced(ballObj, 1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setObjects([{ key: "ball" }])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="main-div">
|
||||||
|
<div id="topbar-div">
|
||||||
|
<button onClick={() => (location.pathname = BASE + "/")}>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
<div id="topbar-left">
|
||||||
|
<SavingState state={saveState} />
|
||||||
|
</div>
|
||||||
|
<div id="title-input-div">
|
||||||
|
<TitleInput
|
||||||
|
style={titleStyle}
|
||||||
|
default_value={name}
|
||||||
|
on_validated={(new_name) => {
|
||||||
|
onNameChange(new_name).then((success) => {
|
||||||
|
setTitleStyle(success ? {} : ERROR_STYLE)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="topbar-right" />
|
||||||
|
</div>
|
||||||
|
<div id="edit-div">
|
||||||
|
<div id="racks">
|
||||||
|
<Rack
|
||||||
|
id="allies-rack"
|
||||||
|
objects={allies}
|
||||||
|
onChange={setAllies}
|
||||||
|
canDetach={(div) =>
|
||||||
|
isBoundsOnCourt(div.getBoundingClientRect())
|
||||||
|
}
|
||||||
|
onElementDetached={onPieceDetach}
|
||||||
|
render={({ team, key }) => (
|
||||||
|
<PlayerPiece
|
||||||
|
team={team}
|
||||||
|
text={key}
|
||||||
|
key={key}
|
||||||
|
hasBall={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Rack
|
||||||
|
id={"objects"}
|
||||||
|
objects={objects}
|
||||||
|
onChange={setObjects}
|
||||||
|
canDetach={(div) =>
|
||||||
|
isBoundsOnCourt(div.getBoundingClientRect())
|
||||||
|
}
|
||||||
|
onElementDetached={onObjectDetach}
|
||||||
|
render={renderCourtObject}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Rack
|
||||||
|
id="opponent-rack"
|
||||||
|
objects={opponents}
|
||||||
|
onChange={setOpponents}
|
||||||
|
canDetach={(div) =>
|
||||||
|
isBoundsOnCourt(div.getBoundingClientRect())
|
||||||
|
}
|
||||||
|
onElementDetached={onPieceDetach}
|
||||||
|
render={({ team, key }) => (
|
||||||
|
<PlayerPiece
|
||||||
|
team={team}
|
||||||
|
text={key}
|
||||||
|
key={key}
|
||||||
|
hasBall={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="court-div">
|
||||||
|
<div id="court-div-bounds">
|
||||||
|
<BasketCourt
|
||||||
|
players={content.players}
|
||||||
|
objects={content.objects}
|
||||||
|
actions={content.actions}
|
||||||
|
onBallMoved={onBallDrop}
|
||||||
|
courtImage={<Court courtType={courtType} />}
|
||||||
|
courtRef={courtDivContentRef}
|
||||||
|
setActions={(actions) =>
|
||||||
|
setContent((content) => ({
|
||||||
|
...content,
|
||||||
|
players: content.players,
|
||||||
|
actions: actions(content.actions),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
renderAction={(action, i) => (
|
||||||
|
<CourtAction
|
||||||
|
key={i}
|
||||||
|
action={action}
|
||||||
|
courtRef={courtDivContentRef}
|
||||||
|
onActionDeleted={() => {
|
||||||
|
setContent((content) => ({
|
||||||
|
...content,
|
||||||
|
actions: content.actions.toSpliced(
|
||||||
|
i,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onActionChanges={(a) =>
|
||||||
|
setContent((content) => ({
|
||||||
|
...content,
|
||||||
|
actions: content.actions.toSpliced(
|
||||||
|
i,
|
||||||
|
1,
|
||||||
|
a,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onPlayerChange={(player) => {
|
||||||
|
const playerBounds = document
|
||||||
|
.getElementById(player.id)!
|
||||||
|
.getBoundingClientRect()
|
||||||
|
if (!isBoundsOnCourt(playerBounds)) {
|
||||||
|
removePlayer(player)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setContent((content) => ({
|
||||||
|
...content,
|
||||||
|
players: toSplicedPlayers(
|
||||||
|
content.players,
|
||||||
|
player,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onPlayerRemove={removePlayer}
|
||||||
|
onBallRemove={removeCourtBall}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBallOnCourt(content: TacticContent) {
|
||||||
|
if (content.players.findIndex((p) => p.hasBall) != -1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return content.objects.findIndex((o) => o.type == "ball") != -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCourtObject(courtObject: RackedCourtObject) {
|
||||||
|
if (courtObject.key == "ball") {
|
||||||
|
return <BallPiece />
|
||||||
|
}
|
||||||
|
throw new Error("unknown racked court object " + courtObject.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Court({ courtType }: { courtType: string }) {
|
||||||
|
return (
|
||||||
|
<div id="court-image-div">
|
||||||
|
{courtType == "PLAIN" ? (
|
||||||
|
<PlainCourt id="court-image" />
|
||||||
|
) : (
|
||||||
|
<HalfCourt id="court-image" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRackPlayers(team: PlayerTeam, 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 debounceAsync<A, B>(
|
||||||
|
f: (args: A) => Promise<B>,
|
||||||
|
delay = 1000,
|
||||||
|
): (args: A) => Promise<B> {
|
||||||
|
let task = 0
|
||||||
|
return (args: A) => {
|
||||||
|
clearTimeout(task)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
task = setTimeout(() => f(args).then(resolve).catch(reject), delay)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(initialSaveState)
|
||||||
|
|
||||||
|
const setContentSynced = useCallback(
|
||||||
|
(newState: SetStateAction<S>) => {
|
||||||
|
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] : []))
|
||||||
|
}
|
@ -0,0 +1,341 @@
|
|||||||
|
import "../style/home/home.css"
|
||||||
|
|
||||||
|
// import AccountSvg from "../assets/account.svg?react"
|
||||||
|
import {Header} from "./template/Header"
|
||||||
|
import {BASE} from "../Constants"
|
||||||
|
import Popup from "../components/Popup";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
interface Tactic {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
creation_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Team {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
picture: string
|
||||||
|
main_color: string
|
||||||
|
second_color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Folder{
|
||||||
|
id:number
|
||||||
|
name:string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({
|
||||||
|
lastTactics,
|
||||||
|
allTactics,
|
||||||
|
folders,
|
||||||
|
teams,
|
||||||
|
username,
|
||||||
|
currentFolder
|
||||||
|
}: {
|
||||||
|
lastTactics: Tactic[]
|
||||||
|
allTactics: Tactic[]
|
||||||
|
folders: Folder[]
|
||||||
|
teams: Team[]
|
||||||
|
username: string
|
||||||
|
currentFolder: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div id="main">
|
||||||
|
<Header username={username} />
|
||||||
|
<Body
|
||||||
|
lastTactics={lastTactics}
|
||||||
|
tactics={allTactics}
|
||||||
|
folders={folders}
|
||||||
|
teams={teams}
|
||||||
|
currentFolder={currentFolder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Body({
|
||||||
|
lastTactics,
|
||||||
|
tactics,
|
||||||
|
folders,
|
||||||
|
teams,
|
||||||
|
currentFolder
|
||||||
|
}: {
|
||||||
|
lastTactics: Tactic[]
|
||||||
|
tactics: Tactic[]
|
||||||
|
folders: Folder[]
|
||||||
|
teams: Team[]
|
||||||
|
currentFolder: number
|
||||||
|
}) {
|
||||||
|
const widthPersonalSpace = 78
|
||||||
|
const widthSideMenu = 100 - widthPersonalSpace
|
||||||
|
return (
|
||||||
|
<div id="body">
|
||||||
|
<PersonalSpace width={widthPersonalSpace} tactics={tactics} folders={folders} currentFolder={currentFolder}/>
|
||||||
|
<SideMenu
|
||||||
|
width={widthSideMenu}
|
||||||
|
lastTactics={lastTactics}
|
||||||
|
teams={teams}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideMenu({
|
||||||
|
width,
|
||||||
|
lastTactics,
|
||||||
|
teams,
|
||||||
|
}: {
|
||||||
|
width: number
|
||||||
|
lastTactics: Tactic[]
|
||||||
|
teams: Team[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="side-menu"
|
||||||
|
style={{
|
||||||
|
width: width + "%",
|
||||||
|
}}>
|
||||||
|
<div id="side-menu-content">
|
||||||
|
<Team teams={teams} />
|
||||||
|
<Tactic lastTactics={lastTactics} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalSpace({
|
||||||
|
width,
|
||||||
|
tactics,
|
||||||
|
folders,
|
||||||
|
currentFolder
|
||||||
|
}: {
|
||||||
|
width: number
|
||||||
|
tactics: Tactic[]
|
||||||
|
folders: Folder[]
|
||||||
|
currentFolder: number
|
||||||
|
}) {
|
||||||
|
const [showPopup, setShowPopup] = useState(false)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="personal-space"
|
||||||
|
style={{
|
||||||
|
width: width + "%",
|
||||||
|
}}>
|
||||||
|
<TitlePersonalSpace />
|
||||||
|
<NewFolder showPopup={showPopup} setShowPopup={setShowPopup} currentFolder={currentFolder}/>
|
||||||
|
<BodyPersonalSpace tactics={tactics} folders={folders} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewFolder({
|
||||||
|
showPopup,
|
||||||
|
setShowPopup,
|
||||||
|
currentFolder
|
||||||
|
}: { showPopup, setShowPopup: (newVal: boolean) => void, currentFolder: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
id="new-folder-button"
|
||||||
|
onClick={() => setShowPopup(true)}
|
||||||
|
>Nouveau dossier</div>
|
||||||
|
<Popup displayState={showPopup} onClose={() => setShowPopup(false)}>
|
||||||
|
<h2>Nouveau dossier</h2>
|
||||||
|
<form action={location.pathname + BASE + "folder/" + currentFolder + "/new"} method="post" id="new-folder-form">
|
||||||
|
<label for="folderName">Nom du dossier</label>
|
||||||
|
<input type="text" id="folderName" name="folderName" required/>
|
||||||
|
<input type="submit" value="Confirmer" id="submit-form"/>
|
||||||
|
</form>
|
||||||
|
</Popup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitlePersonalSpace() {
|
||||||
|
return (
|
||||||
|
<div id="title-personal-space">
|
||||||
|
<h2>Espace Personnel</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableData({ tactics,folders }: { tactics: Tactic[],folders: Folder[]}) {
|
||||||
|
const nbTacticRow = Math.floor(tactics.length / 3) + 1
|
||||||
|
const nbFolderRow = Math.floor(folders.length / 3) + 1
|
||||||
|
let listTactic = Array(nbTacticRow)
|
||||||
|
let listFolder = Array(nbFolderRow)
|
||||||
|
for (let i = 0; i < nbTacticRow; i++) {
|
||||||
|
listTactic[i] = Array(0)
|
||||||
|
}
|
||||||
|
for (let i = 0; i < nbFolderRow ; i++) {
|
||||||
|
listFolder[i] = Array(0)
|
||||||
|
}
|
||||||
|
let i = 0
|
||||||
|
let j = 0
|
||||||
|
tactics.forEach((tactic) => {
|
||||||
|
listTactic[i].push(tactic)
|
||||||
|
j++
|
||||||
|
if (j === 3) {
|
||||||
|
i++
|
||||||
|
j = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
folders.forEach((folder) => {
|
||||||
|
listFolder[i].push(folder)
|
||||||
|
j++
|
||||||
|
if (j === 3) {
|
||||||
|
i++
|
||||||
|
j = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while (i < nbTacticRow) {
|
||||||
|
listTactic[i] = listTactic[i].map((tactic: Tactic) => (
|
||||||
|
<td
|
||||||
|
key={tactic.id}
|
||||||
|
className="data"
|
||||||
|
onClick={() => {
|
||||||
|
location.pathname = BASE + "/tactic/" + tactic.id + "/edit"
|
||||||
|
}}>
|
||||||
|
{truncateString(tactic.name, 25)}
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i = 0
|
||||||
|
while (i < nbFolderRow) {
|
||||||
|
listFolder[i] = listFolder[i].map((folder: Folder) => (
|
||||||
|
<td
|
||||||
|
key={folder.id}
|
||||||
|
className="data"
|
||||||
|
onClick={() => {
|
||||||
|
location.pathname = BASE + "/tactic/" + folder.id + "/edit"
|
||||||
|
}}>
|
||||||
|
{truncateString(folder.name, 25)}
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nbTacticRow == 1) {
|
||||||
|
if (listTactic[0].length < 3) {
|
||||||
|
for (let i = 0; i <= 3 - listTactic[0].length; i++) {
|
||||||
|
listTactic[0].push(<td key={"tdNone" + i}></td>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nbFolderRow == 1) {
|
||||||
|
if (listFolder[0].length < 3) {
|
||||||
|
for (let i = 0; i <= 3 - listFolder[0].length; i++) {
|
||||||
|
listFolder[0].push(<td key={"tdNone" + i}></td>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listTactic.map((tactic, rowIndex) => (
|
||||||
|
<tr key={rowIndex + "row"}>{tactic}</tr>
|
||||||
|
)).concat(listFolder.map((folder, rowIndex) => (
|
||||||
|
<tr key={rowIndex + "row"}>{folder}</tr>
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function BodyPersonalSpace({ tactics,folders }: { tactics: Tactic[],folders: Folder[]}) {
|
||||||
|
let data
|
||||||
|
if (tactics.length == 0) {
|
||||||
|
data = <p>Aucune tactique créée !</p>
|
||||||
|
} else {
|
||||||
|
data = <TableData tactics={tactics} folders={folders}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="body-personal-space">
|
||||||
|
<table>
|
||||||
|
<tbody key="tbody">{data}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Team({ teams }: { teams: Team[] }) {
|
||||||
|
return (
|
||||||
|
<div id="teams">
|
||||||
|
<div className="titre-side-menu">
|
||||||
|
<h2 className="title">Mes équipes</h2>
|
||||||
|
<button
|
||||||
|
className="new"
|
||||||
|
onClick={() => (location.pathname = BASE + "/team/new")}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SetButtonTeam teams={teams} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tactic({ lastTactics }: { lastTactics: Tactic[] }) {
|
||||||
|
return (
|
||||||
|
<div id="tactic">
|
||||||
|
<div className="titre-side-menu">
|
||||||
|
<h2 className="title">Mes dernières stratégies</h2>
|
||||||
|
<button
|
||||||
|
className="new"
|
||||||
|
id="create-tactic"
|
||||||
|
onClick={() => (location.pathname = BASE + "/tactic/new")}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SetButtonTactic tactics={lastTactics} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetButtonTactic({ tactics }: { tactics: Tactic[] }) {
|
||||||
|
const lastTactics = tactics.map((tactic) => (
|
||||||
|
<ButtonLastTactic tactic={tactic} />
|
||||||
|
))
|
||||||
|
return <div className="set-button">{lastTactics}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetButtonTeam({ teams }: { teams: Team[] }) {
|
||||||
|
const listTeam = teams.map((teams) => <ButtonTeam team={teams} />)
|
||||||
|
return <div className="set-button">{listTeam}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonTeam({ team }: { team: Team }) {
|
||||||
|
const name = truncateString(team.name, 20)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
id={"button-team" + team.id}
|
||||||
|
className="button-side-menu data"
|
||||||
|
onClick={() => {
|
||||||
|
location.pathname = BASE + "/team/" + team.id
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonLastTactic({ tactic }: { tactic: Tactic }) {
|
||||||
|
const name = truncateString(tactic.name, 20)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={"button" + tactic.id}
|
||||||
|
className="button-side-menu data"
|
||||||
|
onClick={() => {
|
||||||
|
location.pathname = BASE + "/tactic/" + tactic.id + "/edit"
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateString(name: string, limit: number): string {
|
||||||
|
if (name.length > limit) {
|
||||||
|
name = name.substring(0, limit) + "..."
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
@ -1,28 +1,9 @@
|
|||||||
import "../style/team_panel.css"
|
import "../style/team_panel.css"
|
||||||
import { BASE } from "../Constants"
|
import { BASE } from "../Constants"
|
||||||
import { Member, Team, TeamInfo } from "../model/Team"
|
import { Team, TeamInfo, Member } from "../model/Team"
|
||||||
import { useParams } from "react-router-dom"
|
import { User } from "../model/User"
|
||||||
|
|
||||||
export default function TeamPanelPage() {
|
export default function TeamPanel({
|
||||||
const { teamId } = useParams()
|
|
||||||
const teamInfo = {
|
|
||||||
id: parseInt(teamId!),
|
|
||||||
name: teamId!,
|
|
||||||
mainColor: "#FFFFFF",
|
|
||||||
secondColor: "#000000",
|
|
||||||
picture:
|
|
||||||
"https://a.espncdn.com/combiner/i?img=/i/teamlogos/nba/500/lal.png",
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<TeamPanel
|
|
||||||
team={{ info: teamInfo, members: [] }}
|
|
||||||
currentUserId={0}
|
|
||||||
isCoach={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TeamPanel({
|
|
||||||
isCoach,
|
isCoach,
|
||||||
team,
|
team,
|
||||||
currentUserId,
|
currentUserId,
|
@ -0,0 +1,23 @@
|
|||||||
|
import React, { CSSProperties, useState } from "react"
|
||||||
|
import "../style/visualizer.css"
|
||||||
|
import Court from "../assets/court/full_court.svg"
|
||||||
|
|
||||||
|
export default function Visualizer({ id, name }: { id: number; name: string }) {
|
||||||
|
const [style, setStyle] = useState<CSSProperties>({})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="main">
|
||||||
|
<div id="topbar">
|
||||||
|
<h1>{name}</h1>
|
||||||
|
</div>
|
||||||
|
<div id="court-container">
|
||||||
|
<img
|
||||||
|
id="court"
|
||||||
|
src={Court}
|
||||||
|
style={style}
|
||||||
|
alt="Basketball Court"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
import { BASE } from "../../Constants"
|
||||||
|
import accountSvg from "../../assets/account.svg"
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param param0 username
|
||||||
|
* @returns Header
|
||||||
|
*/
|
||||||
|
export function Header({ username }: { username: string }) {
|
||||||
|
return (
|
||||||
|
<div id="header">
|
||||||
|
<div id="header-left"></div>
|
||||||
|
<div id="header-center">
|
||||||
|
<h1
|
||||||
|
id="iqball"
|
||||||
|
className="clickable"
|
||||||
|
onClick={() => {
|
||||||
|
location.pathname = "/"
|
||||||
|
}}>
|
||||||
|
<span id="IQ">IQ</span>
|
||||||
|
<span id="Ball">Ball</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div id="header-right">
|
||||||
|
<div className="clickable" id="clickable-header-right">
|
||||||
|
{/* <AccountSvg id="img-account" /> */}
|
||||||
|
<img
|
||||||
|
id="img-account"
|
||||||
|
src={accountSvg}
|
||||||
|
onClick={() => {
|
||||||
|
location.pathname = BASE + "/settings"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p id="username">{username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/assets/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>IQBall</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -0,0 +1,12 @@
|
|||||||
|
parameters:
|
||||||
|
phpVersion: 70400
|
||||||
|
level: 6
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
scanFiles:
|
||||||
|
- config.php
|
||||||
|
- sql/database.php
|
||||||
|
- profiles/dev-config-profile.php
|
||||||
|
- profiles/prod-config-profile.php
|
||||||
|
excludePaths:
|
||||||
|
- src/App/react-display-file.php
|
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$hostname = getHostName();
|
||||||
|
$front_url = "http://$hostname:5173";
|
||||||
|
|
||||||
|
const _SUPPORTS_FAST_REFRESH = true;
|
||||||
|
$_data_source_name = "sqlite:${_SERVER['DOCUMENT_ROOT']}/../dev-database.sqlite";
|
||||||
|
|
||||||
|
// no user and password needed for sqlite databases
|
||||||
|
const _DATABASE_USER = null;
|
||||||
|
const _DATABASE_PASSWORD = null;
|
||||||
|
|
||||||
|
function _asset(string $assetURI): string {
|
||||||
|
global $front_url;
|
||||||
|
return $front_url . "/" . $assetURI;
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// This file only exists on production servers, and defines the available assets mappings
|
||||||
|
// in an `ASSETS` array constant.
|
||||||
|
require __DIR__ . "/../views-mappings.php";
|
||||||
|
|
||||||
|
const _SUPPORTS_FAST_REFRESH = false;
|
||||||
|
$database_file = __DIR__ . "/../database.sqlite";
|
||||||
|
$_data_source_name = "sqlite:/$database_file";
|
||||||
|
|
||||||
|
// no user and password needed for sqlite databases
|
||||||
|
const _DATABASE_USER = null;
|
||||||
|
const _DATABASE_PASSWORD = null;
|
||||||
|
|
||||||
|
|
||||||
|
function _asset(string $assetURI): string {
|
||||||
|
// use index.php's base path
|
||||||
|
global $basePath;
|
||||||
|
// If the asset uri does not figure in the available assets array,
|
||||||
|
// fallback to the uri itself.
|
||||||
|
return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI);
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require "../../config.php";
|
||||||
|
require "../../vendor/autoload.php";
|
||||||
|
require "../../sql/database.php";
|
||||||
|
require "../../src/index-utils.php";
|
||||||
|
|
||||||
|
use IQBall\Api\API;
|
||||||
|
use IQBall\Api\Controller\APIAuthController;
|
||||||
|
use IQBall\Api\Controller\APITacticController;
|
||||||
|
use IQBall\App\Session\PhpSessionHandle;
|
||||||
|
use IQBall\Core\Action;
|
||||||
|
use IQBall\Core\Connection;
|
||||||
|
use IQBall\Core\Data\Account;
|
||||||
|
use IQBall\Core\Gateway\AccountGateway;
|
||||||
|
use IQBall\Core\Gateway\TacticInfoGateway;
|
||||||
|
use IQBall\Core\Model\AuthModel;
|
||||||
|
use IQBall\Core\Model\TacticModel;
|
||||||
|
|
||||||
|
function getTacticController(): APITacticController {
|
||||||
|
return new APITacticController(new TacticModel(new TacticInfoGateway(new Connection(get_database()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthController(): APIAuthController {
|
||||||
|
return new APIAuthController(new AuthModel(new AccountGateway(new Connection(get_database()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoutes(): AltoRouter {
|
||||||
|
$router = new AltoRouter();
|
||||||
|
$router->setBasePath(get_public_path(__DIR__));
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the way of being authorised through the API
|
||||||
|
* By checking if an Authorisation header is set, and by expecting its value to be a valid token of an account.
|
||||||
|
* If the header is not set, fallback to the App's PHP session system, and try to extract the account from it.
|
||||||
|
* @return Account|null
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
function tryGetAuthorization(): ?Account {
|
||||||
|
$headers = getallheaders();
|
||||||
|
|
||||||
|
// If no authorization header is set, try fallback to php session.
|
||||||
|
if (!isset($headers['Authorization'])) {
|
||||||
|
$session = PhpSessionHandle::init();
|
||||||
|
return $session->getAccount();
|
||||||
|
}
|
||||||
|
$token = $headers['Authorization'];
|
||||||
|
$gateway = new AccountGateway(new Connection(get_database()));
|
||||||
|
return $gateway->getAccountFromToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Api::render(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization()));
|
@ -0,0 +1 @@
|
|||||||
|
../front/assets
|
@ -0,0 +1 @@
|
|||||||
|
../front
|
@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require "../vendor/autoload.php";
|
||||||
|
require "../config.php";
|
||||||
|
require "../sql/database.php";
|
||||||
|
require "../src/App/react-display.php";
|
||||||
|
require "../src/index-utils.php";
|
||||||
|
|
||||||
|
use IQBall\App\App;
|
||||||
|
use IQBall\App\Controller\AuthController;
|
||||||
|
use IQBall\App\Controller\EditorController;
|
||||||
|
use IQBall\App\Controller\TeamController;
|
||||||
|
use IQBall\App\Controller\UserController;
|
||||||
|
use IQBall\App\Controller\VisualizerController;
|
||||||
|
use IQBall\App\Session\MutableSessionHandle;
|
||||||
|
use IQBall\App\Session\PhpSessionHandle;
|
||||||
|
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;
|
||||||
|
use IQBall\Core\Gateway\TeamGateway;
|
||||||
|
use IQBall\Core\Http\HttpCodes;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Model\AuthModel;
|
||||||
|
use IQBall\Core\Model\TacticModel;
|
||||||
|
use IQBall\Core\Model\TeamModel;
|
||||||
|
use IQBall\Core\Validation\ValidationFail;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
function getConnection(): Connection {
|
||||||
|
return new Connection(get_database());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserController(): UserController {
|
||||||
|
return new UserController(new TacticModel(new TacticInfoGateway(getConnection())), new TeamModel(new TeamGateway(getConnection()), new MemberGateway(getConnection()), new AccountGateway(getConnection())),new \IQBall\Core\Model\PersonalSpaceModel(new \IQBall\Core\Gateway\PersonalSpaceGateway(getConnection()),new TacticInfoGateway(getConnection())));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisualizerController(): VisualizerController {
|
||||||
|
return new VisualizerController(new TacticModel(new TacticInfoGateway(getConnection())));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditorController(): EditorController {
|
||||||
|
return new EditorController(new TacticModel(new TacticInfoGateway(getConnection())));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTeamController(): TeamController {
|
||||||
|
$con = getConnection();
|
||||||
|
return new TeamController(new TeamModel(new TeamGateway($con), new MemberGateway($con), new AccountGateway($con)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthController(): AuthController {
|
||||||
|
return new AuthController(new AuthModel(new AccountGateway(getConnection())));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTwig(): Environment {
|
||||||
|
global $basePath;
|
||||||
|
$fl = new FilesystemLoader("../src/App/Views");
|
||||||
|
$twig = new Environment($fl);
|
||||||
|
|
||||||
|
$twig->addFunction(new TwigFunction('path', fn(string $str) => "$basePath$str"));
|
||||||
|
|
||||||
|
return $twig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoutes(): AltoRouter {
|
||||||
|
global $basePath;
|
||||||
|
|
||||||
|
$ar = new AltoRouter();
|
||||||
|
$ar->setBasePath($basePath);
|
||||||
|
|
||||||
|
//authentication
|
||||||
|
$ar->map("GET", "/login", Action::noAuth(fn() => getAuthController()->displayLogin()));
|
||||||
|
$ar->map("GET", "/register", Action::noAuth(fn() => getAuthController()->displayRegister()));
|
||||||
|
$ar->map("POST", "/login", Action::noAuth(fn(SessionHandle $s) => getAuthController()->login($_POST, $s)));
|
||||||
|
$ar->map("POST", "/register", Action::noAuth(fn(SessionHandle $s) => getAuthController()->register($_POST, $s)));
|
||||||
|
|
||||||
|
//user-related
|
||||||
|
$ar->map("GET", "/", Action::auth(fn(SessionHandle $s) => getUserController()->home($s)));
|
||||||
|
$ar->map("GET", "/home", Action::auth(fn(SessionHandle $s) => getUserController()->home($s)));
|
||||||
|
$ar->map("GET", "/settings", Action::auth(fn(SessionHandle $s) => getUserController()->settings($s)));
|
||||||
|
$ar->map("GET", "/disconnect", Action::auth(fn(MutableSessionHandle $s) => getUserController()->disconnect($s)));
|
||||||
|
|
||||||
|
//folder-related
|
||||||
|
$ar->map("POST", "/folder/[i:idParent]/new", Action::auth(fn(int $id,SessionHandle $s) => getUserController()->createFolder($s,$_POST,$id)));
|
||||||
|
|
||||||
|
//tactic-related
|
||||||
|
$ar->map("GET", "/tactic/[i:id]/view", Action::auth(fn(int $id, SessionHandle $s) => getVisualizerController()->openVisualizer($id, $s)));
|
||||||
|
$ar->map("GET", "/tactic/[i:id]/edit", Action::auth(fn(int $id, SessionHandle $s) => getEditorController()->openEditor($id, $s)));
|
||||||
|
// don't require an authentication to run this action.
|
||||||
|
// If the user is not connected, the tactic will never save.
|
||||||
|
$ar->map("GET", "/tactic/new", Action::noAuth(fn() => getEditorController()->createNew()));
|
||||||
|
$ar->map("GET", "/tactic/new/plain", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNewOfKind(CourtType::plain(), $s)));
|
||||||
|
$ar->map("GET", "/tactic/new/half", Action::noAuth(fn(SessionHandle $s) => getEditorController()->createNewOfKind(CourtType::half(), $s)));
|
||||||
|
|
||||||
|
//team-related
|
||||||
|
$ar->map("GET", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->displayCreateTeam($s)));
|
||||||
|
$ar->map("POST", "/team/new", Action::auth(fn(SessionHandle $s) => getTeamController()->submitTeam($_POST, $s)));
|
||||||
|
$ar->map("GET", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->displayListTeamByName($s)));
|
||||||
|
$ar->map("POST", "/team/search", Action::auth(fn(SessionHandle $s) => getTeamController()->listTeamByName($_POST, $s)));
|
||||||
|
$ar->map("GET", "/team/[i:id]", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->displayTeam($id, $s)));
|
||||||
|
$ar->map("GET", "/team/[i:id]/delete", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->deleteTeamById($id, $s)));
|
||||||
|
$ar->map("GET", "/team/[i:id]/addMember", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->displayAddMember($id, $s)));
|
||||||
|
$ar->map("POST", "/team/[i:id]/addMember", Action::auth(fn(int $id, SessionHandle $s) => getTeamController()->addMember($id, $_POST, $s)));
|
||||||
|
$ar->map("GET", "/team/[i:idTeam]/remove/[i:idMember]", Action::auth(fn(int $idTeam, int $idMember, SessionHandle $s) => getTeamController()->deleteMember($idTeam, $idMember, $s)));
|
||||||
|
$ar->map("GET", "/team/[i:id]/edit", Action::auth(fn(int $idTeam, SessionHandle $s) => getTeamController()->displayEditTeam($idTeam, $s)));
|
||||||
|
$ar->map("POST", "/team/[i:id]/edit", Action::auth(fn(int $idTeam, SessionHandle $s) => getTeamController()->editTeam($idTeam, $_POST, $s)));
|
||||||
|
|
||||||
|
|
||||||
|
return $ar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMatch($match, MutableSessionHandle $session): HttpResponse {
|
||||||
|
global $basePath;
|
||||||
|
if (!$match) {
|
||||||
|
return ViewHttpResponse::twig("error.html.twig", [
|
||||||
|
'failures' => [ValidationFail::notFound("Could not find page {$_SERVER['REQUEST_URI']}.")],
|
||||||
|
], HttpCodes::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return App::runAction($basePath . '/login', $match['target'], $match['params'], $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
//this is a global variable
|
||||||
|
$basePath = get_public_path(__DIR__);
|
||||||
|
|
||||||
|
App::render(runMatch(getRoutes()->match(), PhpSessionHandle::init()), fn() => getTwig());
|
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PDO The PDO instance of the configuration's database connexion.
|
||||||
|
*/
|
||||||
|
function get_database(): PDO {
|
||||||
|
// defined by profiles.
|
||||||
|
global $data_source_name;
|
||||||
|
$pdo = new PDO($data_source_name, DATABASE_USER, DATABASE_PASSWORD, [PDO::ERRMODE_EXCEPTION]);
|
||||||
|
|
||||||
|
$database_exists = $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")->fetchColumn() > 0;
|
||||||
|
|
||||||
|
if ($database_exists) {
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir(__DIR__) as $file) {
|
||||||
|
if (preg_match("/.*\.sql$/i", $file)) {
|
||||||
|
$content = file_get_contents(__DIR__ . "/" . $file);
|
||||||
|
|
||||||
|
$pdo->exec($content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
-- drop tables here
|
||||||
|
DROP TABLE IF EXISTS Account;
|
||||||
|
DROP TABLE IF EXISTS Tactic;
|
||||||
|
DROP TABLE IF EXISTS Team;
|
||||||
|
DROP TABLE IF EXISTS User;
|
||||||
|
DROP TABLE IF EXISTS Member;
|
||||||
|
DROP TABLE IF EXISTS TacticFolder;
|
||||||
|
DROP TABLE IF EXISTS TacticFolderLink;
|
||||||
|
--
|
||||||
|
CREATE TABLE Account
|
||||||
|
(
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email varchar UNIQUE NOT NULL,
|
||||||
|
username varchar NOT NULL,
|
||||||
|
token varchar UNIQUE NOT NULL,
|
||||||
|
hash varchar NOT NULL,
|
||||||
|
profilePicture varchar NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Tactic
|
||||||
|
(
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name varchar NOT NULL,
|
||||||
|
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
owner integer NOT NULL,
|
||||||
|
content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL,
|
||||||
|
court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
|
||||||
|
FOREIGN KEY (owner) REFERENCES Account
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE TacticFolder
|
||||||
|
(
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name varchar NOT NULL,
|
||||||
|
owner integer NOT NULL,
|
||||||
|
tactic_folder_parent integer,
|
||||||
|
FOREIGN KEY (owner) REFERENCES Account,
|
||||||
|
FOREIGN KEY (tactic_folder_parent) REFERENCES TacticFolder
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE TacticFolderLink
|
||||||
|
(
|
||||||
|
id_folder integer NOT NULL,
|
||||||
|
id_tactic integer NOT NULL,
|
||||||
|
FOREIGN KEY (id_folder) REFERENCES TacticFolder,
|
||||||
|
FOREIGN KEY (id_tactic) REFERENCES Tactic
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Team
|
||||||
|
(
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
name varchar NOT NULL,
|
||||||
|
picture varchar NOT NULL,
|
||||||
|
main_color varchar NOT NULL,
|
||||||
|
second_color varchar NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE Member
|
||||||
|
(
|
||||||
|
id_team integer NOT NULL,
|
||||||
|
id_user integer NOT NULL,
|
||||||
|
role text CHECK (role IN ('COACH', 'PLAYER')) NOT NULL,
|
||||||
|
FOREIGN KEY (id_team) REFERENCES Team (id),
|
||||||
|
FOREIGN KEY (id_user) REFERENCES Account (id)
|
||||||
|
);
|
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\Api;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use IQBall\Core\Action;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Http\JsonHttpResponse;
|
||||||
|
use IQBall\Core\Validation\ValidationFail;
|
||||||
|
|
||||||
|
class API {
|
||||||
|
public static function render(HttpResponse $response): void {
|
||||||
|
http_response_code($response->getCode());
|
||||||
|
|
||||||
|
foreach ($response->getHeaders() as $header => $value) {
|
||||||
|
header("$header: $value");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof JsonHttpResponse) {
|
||||||
|
header('Content-type: application/json');
|
||||||
|
echo $response->getJson();
|
||||||
|
} elseif (get_class($response) != HttpResponse::class) {
|
||||||
|
throw new Exception("API returned unknown Http Response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $match
|
||||||
|
* @param callable $tryGetAuthorization function to return an authorisation object for the given action (if required)
|
||||||
|
* @return HttpResponse
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function handleMatch(array $match, callable $tryGetAuthorization): HttpResponse {
|
||||||
|
if (!$match) {
|
||||||
|
return new JsonHttpResponse([ValidationFail::notFound("not found")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $match['target'];
|
||||||
|
if (!$action instanceof Action) {
|
||||||
|
throw new Exception("routed action is not an AppAction object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$auth = null;
|
||||||
|
|
||||||
|
if ($action->isAuthRequired()) {
|
||||||
|
$auth = call_user_func($tryGetAuthorization);
|
||||||
|
if ($auth == null) {
|
||||||
|
return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action->run($match['params'], $auth);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\Api\Controller;
|
||||||
|
|
||||||
|
use IQBall\App\Control;
|
||||||
|
use IQBall\Core\Http\HttpCodes;
|
||||||
|
use IQBall\Core\Http\HttpRequest;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Http\JsonHttpResponse;
|
||||||
|
use IQBall\Core\Model\AuthModel;
|
||||||
|
use IQBall\Core\Validation\DefaultValidators;
|
||||||
|
|
||||||
|
class APIAuthController {
|
||||||
|
private AuthModel $model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param AuthModel $model
|
||||||
|
*/
|
||||||
|
public function __construct(AuthModel $model) {
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From given email address and password, authenticate the user and respond with its authorization token.
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function authorize(): HttpResponse {
|
||||||
|
return Control::runChecked([
|
||||||
|
"email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)],
|
||||||
|
"password" => [DefaultValidators::lenBetween(6, 256)],
|
||||||
|
], function (HttpRequest $req) {
|
||||||
|
$failures = [];
|
||||||
|
$account = $this->model->login($req["email"], $req["password"], $failures);
|
||||||
|
|
||||||
|
if (!empty($failures)) {
|
||||||
|
return new JsonHttpResponse($failures, HttpCodes::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonHttpResponse(["authorization" => $account->getToken()]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\Api\Controller;
|
||||||
|
|
||||||
|
use IQBall\App\Control;
|
||||||
|
use IQBall\Core\Data\Account;
|
||||||
|
use IQBall\Core\Http\HttpCodes;
|
||||||
|
use IQBall\Core\Http\HttpRequest;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Http\JsonHttpResponse;
|
||||||
|
use IQBall\Core\Model\TacticModel;
|
||||||
|
use IQBall\Core\Validation\FieldValidationFail;
|
||||||
|
use IQBall\Core\Validation\DefaultValidators;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint related to tactics
|
||||||
|
*/
|
||||||
|
class APITacticController {
|
||||||
|
private TacticModel $model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TacticModel $model
|
||||||
|
*/
|
||||||
|
public function __construct(TacticModel $model) {
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update name of tactic, specified by tactic identifier, given in url.
|
||||||
|
* @param int $tactic_id
|
||||||
|
* @param Account $account
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function updateName(int $tactic_id, Account $account): HttpResponse {
|
||||||
|
return Control::runChecked([
|
||||||
|
"name" => [DefaultValidators::lenBetween(1, 50), DefaultValidators::nameWithSpaces()],
|
||||||
|
], function (HttpRequest $request) use ($tactic_id, $account) {
|
||||||
|
|
||||||
|
$failures = $this->model->updateName($tactic_id, $request["name"], $account->getUser()->getId());
|
||||||
|
|
||||||
|
if (!empty($failures)) {
|
||||||
|
//TODO find a system to handle Unauthorized error codes more easily from failures.
|
||||||
|
return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpResponse::fromCode(HttpCodes::OK);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $id
|
||||||
|
* @param Account $account
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function saveContent(int $id, Account $account): HttpResponse {
|
||||||
|
return 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,275 +0,0 @@
|
|||||||
import {
|
|
||||||
BrowserRouter,
|
|
||||||
Navigate,
|
|
||||||
Outlet,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
useLocation,
|
|
||||||
} from "react-router-dom"
|
|
||||||
|
|
||||||
import { Header } from "./pages/template/Header.tsx"
|
|
||||||
import "./style/app.css"
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
lazy,
|
|
||||||
ReactNode,
|
|
||||||
Suspense,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { BASE } from "./Constants.ts"
|
|
||||||
import { Authentication, Fetcher } from "./app/Fetcher.ts"
|
|
||||||
import { User } from "./model/User.ts"
|
|
||||||
|
|
||||||
const HomePage = lazy(() => import("./pages/HomePage.tsx"))
|
|
||||||
const LoginPage = lazy(() => import("./pages/LoginPage.tsx"))
|
|
||||||
const RegisterPage = lazy(() => import("./pages/RegisterPage.tsx"))
|
|
||||||
const NotFoundPage = lazy(() => import("./pages/404.tsx"))
|
|
||||||
const CreateTeamPage = lazy(() => import("./pages/CreateTeamPage.tsx"))
|
|
||||||
const TeamPanelPage = lazy(() => import("./pages/TeamPanel.tsx"))
|
|
||||||
const NewTacticPage = lazy(() => import("./pages/NewTacticPage.tsx"))
|
|
||||||
const Editor = lazy(() => import("./pages/Editor.tsx"))
|
|
||||||
const VisualizerPage = lazy(() => import("./pages/VisualizerPage.tsx"))
|
|
||||||
const Settings = lazy(() => import("./pages/Settings.tsx"))
|
|
||||||
|
|
||||||
const TOKEN_REFRESH_INTERVAL_MS = 60 * 1000
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
function suspense(node: ReactNode) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<p>Loading, please wait...</p>}>
|
|
||||||
{node}
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetcher = useMemo(() => new Fetcher(getStoredAuthentication()), [])
|
|
||||||
const [user, setUser] = useState<User | null>(null)
|
|
||||||
|
|
||||||
const handleAuthSuccess = useCallback(
|
|
||||||
async (auth: Authentication) => {
|
|
||||||
fetcher.updateAuthentication(auth)
|
|
||||||
const user = await fetchUser(fetcher)
|
|
||||||
setUser(user)
|
|
||||||
storeAuthentication(auth)
|
|
||||||
},
|
|
||||||
[fetcher],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetcher.fetchAPIGet("auth/keep-alive")
|
|
||||||
}, TOKEN_REFRESH_INTERVAL_MS)
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [fetcher])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="app">
|
|
||||||
<FetcherContext.Provider value={fetcher}>
|
|
||||||
<SignedInUserContext.Provider
|
|
||||||
value={{
|
|
||||||
user,
|
|
||||||
setUser,
|
|
||||||
}}>
|
|
||||||
<BrowserRouter basename={BASE}>
|
|
||||||
<Outlet />
|
|
||||||
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path={"/login"}
|
|
||||||
element={suspense(
|
|
||||||
<LoginPage onSuccess={handleAuthSuccess} />,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/register"}
|
|
||||||
element={suspense(
|
|
||||||
<RegisterPage
|
|
||||||
onSuccess={handleAuthSuccess}
|
|
||||||
/>,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path={"/"} element={suspense(<AppLayout />)}>
|
|
||||||
<Route
|
|
||||||
path={"/"}
|
|
||||||
element={suspense(
|
|
||||||
<LoggedInPage>
|
|
||||||
<HomePage />
|
|
||||||
</LoggedInPage>,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/home"}
|
|
||||||
element={suspense(
|
|
||||||
<LoggedInPage>
|
|
||||||
<HomePage />
|
|
||||||
</LoggedInPage>,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/settings"}
|
|
||||||
element={suspense(
|
|
||||||
<LoggedInPage>
|
|
||||||
<Settings />
|
|
||||||
</LoggedInPage>,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/team/new"}
|
|
||||||
element={suspense(<CreateTeamPage />)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/team/:teamId"}
|
|
||||||
element={suspense(
|
|
||||||
<LoggedInPage>
|
|
||||||
<TeamPanelPage />
|
|
||||||
</LoggedInPage>,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/tactic/new"}
|
|
||||||
element={suspense(<NewTacticPage />)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/tactic/:tacticId/edit"}
|
|
||||||
element={suspense(
|
|
||||||
<LoggedInPage>
|
|
||||||
<Editor guestMode={false} />
|
|
||||||
</LoggedInPage>,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/tactic/:tacticId/view"}
|
|
||||||
element={suspense(
|
|
||||||
<LoggedInPage>
|
|
||||||
<VisualizerPage guestMode={false} />
|
|
||||||
</LoggedInPage>,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/tactic/view-guest"}
|
|
||||||
element={suspense(
|
|
||||||
<VisualizerPage guestMode={true} />,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/tactic/edit-guest"}
|
|
||||||
element={suspense(
|
|
||||||
<Editor guestMode={true} />,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path={"*"}
|
|
||||||
element={suspense(<NotFoundPage />)}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
</SignedInUserContext.Provider>
|
|
||||||
</FetcherContext.Provider>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUser(fetcher: Fetcher): Promise<User> {
|
|
||||||
const response = await fetcher.fetchAPIGet("user")
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw Error(
|
|
||||||
"Could not retrieve user information : " + (await response.text()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_AUTH_KEY = "token"
|
|
||||||
|
|
||||||
function getStoredAuthentication(): Authentication {
|
|
||||||
const storedUser = localStorage.getItem(STORAGE_AUTH_KEY)
|
|
||||||
return storedUser == null ? null : JSON.parse(storedUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeAuthentication(auth: Authentication) {
|
|
||||||
localStorage.setItem(STORAGE_AUTH_KEY, JSON.stringify(auth))
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoggedInPageProps {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
enum UserFetchingState {
|
|
||||||
FETCHING,
|
|
||||||
FETCHED,
|
|
||||||
ERROR,
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoggedInPage({ children }: LoggedInPageProps) {
|
|
||||||
const [user, setUser] = useUser()
|
|
||||||
const fetcher = useAppFetcher()
|
|
||||||
const [userFetchingState, setUserFetchingState] = useState(
|
|
||||||
user === null ? UserFetchingState.FETCHING : UserFetchingState.FETCHED,
|
|
||||||
)
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function initUser() {
|
|
||||||
try {
|
|
||||||
const user = await fetchUser(fetcher)
|
|
||||||
setUser(user)
|
|
||||||
setUserFetchingState(UserFetchingState.FETCHED)
|
|
||||||
} catch (e) {
|
|
||||||
setUserFetchingState(UserFetchingState.ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userFetchingState === UserFetchingState.FETCHING) initUser()
|
|
||||||
}, [fetcher, setUser, userFetchingState])
|
|
||||||
|
|
||||||
switch (userFetchingState) {
|
|
||||||
case UserFetchingState.ERROR:
|
|
||||||
return (
|
|
||||||
<Navigate
|
|
||||||
to={"/login"}
|
|
||||||
replace
|
|
||||||
state={{ from: location.pathname }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case UserFetchingState.FETCHED:
|
|
||||||
return children
|
|
||||||
case UserFetchingState.FETCHING:
|
|
||||||
return <p>Fetching user...</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppLayout() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserContext {
|
|
||||||
user: User | null
|
|
||||||
setUser: (user: User | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SignedInUserContext = createContext<UserContext | null>(null)
|
|
||||||
const FetcherContext = createContext(new Fetcher())
|
|
||||||
|
|
||||||
export function useAppFetcher() {
|
|
||||||
return useContext(FetcherContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUser(): [User | null, (user: User | null) => void] {
|
|
||||||
const { user, setUser } = useContext(SignedInUserContext)!
|
|
||||||
return [user, setUser]
|
|
||||||
}
|
|
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\App;
|
||||||
|
|
||||||
|
use IQBall\App\Session\MutableSessionHandle;
|
||||||
|
use IQBall\Core\Action;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Http\JsonHttpResponse;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
|
use Twig\Error\RuntimeError;
|
||||||
|
use Twig\Error\SyntaxError;
|
||||||
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
|
||||||
|
class App {
|
||||||
|
/**
|
||||||
|
* renders (prints out) given HttpResponse to the client
|
||||||
|
* @param HttpResponse $response
|
||||||
|
* @param callable(): Environment $twigSupplier
|
||||||
|
* @return void
|
||||||
|
* @throws LoaderError
|
||||||
|
* @throws RuntimeError
|
||||||
|
* @throws SyntaxError
|
||||||
|
*/
|
||||||
|
public static function render(HttpResponse $response, callable $twigSupplier): void {
|
||||||
|
http_response_code($response->getCode());
|
||||||
|
|
||||||
|
foreach ($response->getHeaders() as $header => $value) {
|
||||||
|
header("$header: $value");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof ViewHttpResponse) {
|
||||||
|
self::renderView($response, $twigSupplier);
|
||||||
|
} elseif ($response instanceof JsonHttpResponse) {
|
||||||
|
header('Content-type: application/json');
|
||||||
|
echo $response->getJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renders (prints out) given ViewHttpResponse to the client
|
||||||
|
* @param ViewHttpResponse $response
|
||||||
|
* @param callable(): Environment $twigSupplier
|
||||||
|
* @return void
|
||||||
|
* @throws LoaderError
|
||||||
|
* @throws RuntimeError
|
||||||
|
* @throws SyntaxError
|
||||||
|
*/
|
||||||
|
private static function renderView(ViewHttpResponse $response, callable $twigSupplier): void {
|
||||||
|
$file = $response->getFile();
|
||||||
|
$args = $response->getArguments();
|
||||||
|
|
||||||
|
switch ($response->getViewKind()) {
|
||||||
|
case ViewHttpResponse::REACT_VIEW:
|
||||||
|
send_react_front($file, $args);
|
||||||
|
break;
|
||||||
|
case ViewHttpResponse::TWIG_VIEW:
|
||||||
|
try {
|
||||||
|
$twig = call_user_func($twigSupplier);
|
||||||
|
$twig->display($file, $args);
|
||||||
|
} catch (RuntimeError|SyntaxError|LoaderError $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo "There was an error rendering your view, please refer to an administrator.\nlogs date: " . date("YYYD, d M Y H:i:s");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* run a user action, and return the generated response
|
||||||
|
* @param string $authRoute the route towards an authentication page to response with a redirection
|
||||||
|
* if the run action requires auth but session does not contain a logged-in account.
|
||||||
|
* @param Action<MutableSessionHandle> $action
|
||||||
|
* @param mixed[] $params
|
||||||
|
* @param MutableSessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public static function runAction(string $authRoute, Action $action, array $params, MutableSessionHandle $session): HttpResponse {
|
||||||
|
if ($action->isAuthRequired()) {
|
||||||
|
$account = $session->getAccount();
|
||||||
|
if ($account == null) {
|
||||||
|
// put in the session the initial url the user wanted to get
|
||||||
|
$session->setInitialTarget($_SERVER['REQUEST_URI']);
|
||||||
|
return HttpResponse::redirect($authRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action->run($params, $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\App;
|
||||||
|
|
||||||
|
use IQBall\Core\Http\HttpCodes;
|
||||||
|
use IQBall\Core\Http\HttpRequest;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Validation\ValidationFail;
|
||||||
|
use IQBall\Core\Validation\Validator;
|
||||||
|
|
||||||
|
class Control {
|
||||||
|
/**
|
||||||
|
* Runs given callback, if the request's json validates the given schema.
|
||||||
|
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
|
||||||
|
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
|
||||||
|
* THe callback must accept an HttpRequest, and return an HttpResponse object.
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public static function runChecked(array $schema, callable $run): HttpResponse {
|
||||||
|
$request_body = file_get_contents('php://input');
|
||||||
|
$payload_obj = json_decode($request_body);
|
||||||
|
if (!$payload_obj instanceof \stdClass) {
|
||||||
|
$fail = new ValidationFail("bad-payload", "request body is not a valid json object");
|
||||||
|
return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$payload = get_object_vars($payload_obj);
|
||||||
|
return self::runCheckedFrom($payload, $schema, $run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs given callback, if the given request data array validates the given schema.
|
||||||
|
* @param array<string, mixed> $data the request's data array.
|
||||||
|
* @param array<string, Validator[]> $schema an array of `fieldName => DefaultValidators` which represents the request object schema
|
||||||
|
* @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema.
|
||||||
|
* THe callback must accept an HttpRequest, and return an HttpResponse object.
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse {
|
||||||
|
$fails = [];
|
||||||
|
$request = HttpRequest::from($data, $fails, $schema);
|
||||||
|
|
||||||
|
if (!empty($fails)) {
|
||||||
|
return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return call_user_func_array($run, [$request]);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\App\Controller;
|
||||||
|
|
||||||
|
use IQBall\App\Session\MutableSessionHandle;
|
||||||
|
use IQBall\App\ViewHttpResponse;
|
||||||
|
use IQBall\Core\Http\HttpRequest;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Model\AuthModel;
|
||||||
|
use IQBall\Core\Validation\DefaultValidators;
|
||||||
|
|
||||||
|
class AuthController {
|
||||||
|
private AuthModel $model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param AuthModel $model
|
||||||
|
*/
|
||||||
|
public function __construct(AuthModel $model) {
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayRegister(): HttpResponse {
|
||||||
|
return ViewHttpResponse::twig("display_register.html.twig", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registers given account
|
||||||
|
* @param mixed[] $request
|
||||||
|
* @param MutableSessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function register(array $request, MutableSessionHandle $session): HttpResponse {
|
||||||
|
$fails = [];
|
||||||
|
HttpRequest::from($request, $fails, [
|
||||||
|
"username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)],
|
||||||
|
"password" => [DefaultValidators::lenBetween(6, 256)],
|
||||||
|
"confirmpassword" => [DefaultValidators::lenBetween(6, 256)],
|
||||||
|
"email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($fails)) {
|
||||||
|
if (!(in_array($request['username'], $fails)) or !(in_array($request['email'], $fails))) {
|
||||||
|
return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails,'username' => $request['username'],'email' => $request['email']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$account = $this->model->register($request['username'], $request["password"], $request['confirmpassword'], $request['email'], $fails);
|
||||||
|
if (!empty($fails)) {
|
||||||
|
return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]);
|
||||||
|
}
|
||||||
|
$session->setAccount($account);
|
||||||
|
|
||||||
|
$target_url = $session->getInitialTarget();
|
||||||
|
if ($target_url != null) {
|
||||||
|
return HttpResponse::redirect_absolute($target_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpResponse::redirect("/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function displayLogin(): HttpResponse {
|
||||||
|
return ViewHttpResponse::twig("display_login.html.twig", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* logins given account credentials
|
||||||
|
* @param mixed[] $request
|
||||||
|
* @param MutableSessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function login(array $request, MutableSessionHandle $session): HttpResponse {
|
||||||
|
$fails = [];
|
||||||
|
$account = $this->model->login($request['email'], $request['password'], $fails);
|
||||||
|
if (!empty($fails)) {
|
||||||
|
return ViewHttpResponse::twig("display_login.html.twig", ['fails' => $fails]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->setAccount($account);
|
||||||
|
|
||||||
|
$target_url = $session->getInitialTarget();
|
||||||
|
$session->setInitialTarget(null);
|
||||||
|
if ($target_url != null) {
|
||||||
|
return HttpResponse::redirect_absolute($target_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpResponse::redirect("/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
public function __construct(TacticModel $model) {
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TacticInfo $tactic
|
||||||
|
* @return ViewHttpResponse the editor view for given tactic
|
||||||
|
*/
|
||||||
|
private function openEditorFor(TacticInfo $tactic): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::react("views/Editor.tsx", [
|
||||||
|
"id" => $tactic->getId(),
|
||||||
|
"name" => $tactic->getName(),
|
||||||
|
"content" => $tactic->getContent(),
|
||||||
|
"courtType" => $tactic->getCourtType()->name(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createNew(): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::react("views/NewTacticPanel.tsx", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ViewHttpResponse the editor view for a test tactic.
|
||||||
|
*/
|
||||||
|
private function openTestEditor(CourtType $courtType): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::react("views/Editor.tsx", [
|
||||||
|
"id" => -1, //-1 id means that the editor will not support saves
|
||||||
|
"name" => TacticModel::TACTIC_DEFAULT_NAME,
|
||||||
|
"content" => '{"players": [], "objects": [], "actions": []}',
|
||||||
|
"courtType" => $courtType->name(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* creates a new empty tactic, with default name
|
||||||
|
* If the given session does not contain a connected account,
|
||||||
|
* open a test editor.
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @param CourtType $type
|
||||||
|
* @return ViewHttpResponse the editor view
|
||||||
|
*/
|
||||||
|
public function createNewOfKind(CourtType $type, SessionHandle $session): ViewHttpResponse {
|
||||||
|
|
||||||
|
$action = $session->getAccount();
|
||||||
|
|
||||||
|
if ($action == null) {
|
||||||
|
return $this->openTestEditor($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tactic = $this->model->makeNewDefault($session->getAccount()->getUser()->getId(), $type);
|
||||||
|
return $this->openEditorFor($tactic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns an editor view for a given tactic
|
||||||
|
* @param int $id the targeted tactic identifier
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse
|
||||||
|
*/
|
||||||
|
public function openEditor(int $id, SessionHandle $session): ViewHttpResponse {
|
||||||
|
$tactic = $this->model->get($id);
|
||||||
|
|
||||||
|
$failure = TacticValidator::validateAccess($id, $tactic, $session->getAccount()->getUser()->getId());
|
||||||
|
|
||||||
|
if ($failure != null) {
|
||||||
|
return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->openEditorFor($tactic);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\App\Controller;
|
||||||
|
|
||||||
|
use IQBall\App\Session\SessionHandle;
|
||||||
|
use IQBall\App\ViewHttpResponse;
|
||||||
|
use IQBall\Core\Data\Account;
|
||||||
|
use IQBall\Core\Http\HttpCodes;
|
||||||
|
use IQBall\Core\Http\HttpRequest;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Model\TeamModel;
|
||||||
|
use IQBall\Core\Validation\FieldValidationFail;
|
||||||
|
use IQBall\Core\Validation\ValidationFail;
|
||||||
|
use IQBall\Core\Validation\DefaultValidators;
|
||||||
|
|
||||||
|
class TeamController {
|
||||||
|
private TeamModel $model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TeamModel $model
|
||||||
|
*/
|
||||||
|
public function __construct(TeamModel $model) {
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse the team creation panel
|
||||||
|
*/
|
||||||
|
public function displayCreateTeam(SessionHandle $session): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::twig("insert_team.html.twig", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse the team panel to delete a member
|
||||||
|
*/
|
||||||
|
public function displayDeleteMember(SessionHandle $session): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::twig("delete_member.html.twig", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create a new team from given request name, mainColor, secondColor and picture url
|
||||||
|
* @param array<string, mixed> $request
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function submitTeam(array $request, SessionHandle $session): HttpResponse {
|
||||||
|
$failures = [];
|
||||||
|
$request = HttpRequest::from($request, $failures, [
|
||||||
|
"name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()],
|
||||||
|
"main_color" => [DefaultValidators::hexColor()],
|
||||||
|
"second_color" => [DefaultValidators::hexColor()],
|
||||||
|
"picture" => [DefaultValidators::isURL()],
|
||||||
|
]);
|
||||||
|
if (!empty($failures)) {
|
||||||
|
$badFields = [];
|
||||||
|
foreach ($failures as $e) {
|
||||||
|
if ($e instanceof FieldValidationFail) {
|
||||||
|
$badFields[] = $e->getFieldName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ViewHttpResponse::twig('insert_team.html.twig', ['bad_fields' => $badFields]);
|
||||||
|
}
|
||||||
|
$teamId = $this->model->createTeam($request['name'], $request['picture'], $request['main_color'], $request['second_color']);
|
||||||
|
$this->model->addMember($session->getAccount()->getUser()->getEmail(), $teamId, 'COACH');
|
||||||
|
return HttpResponse::redirect('/team/' . $teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse the panel to search a team by its name
|
||||||
|
*/
|
||||||
|
public function displayListTeamByName(SessionHandle $session): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::twig("list_team_by_name.html.twig", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns a view that contains all the teams description whose name matches the given name needle.
|
||||||
|
* @param array<string, mixed> $request
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function listTeamByName(array $request, SessionHandle $session): HttpResponse {
|
||||||
|
$errors = [];
|
||||||
|
$request = HttpRequest::from($request, $errors, [
|
||||||
|
"name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($errors) && $errors[0] instanceof FieldValidationFail) {
|
||||||
|
$badField = $errors[0]->getFieldName();
|
||||||
|
return ViewHttpResponse::twig('list_team_by_name.html.twig', ['bad_field' => $badField]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$teams = $this->model->listByName($request['name'], $session->getAccount()->getUser()->getId());
|
||||||
|
|
||||||
|
if (empty($teams)) {
|
||||||
|
return ViewHttpResponse::twig('display_teams.html.twig', []);
|
||||||
|
}
|
||||||
|
return ViewHttpResponse::twig('display_teams.html.twig', ['teams' => $teams]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a team with its id
|
||||||
|
* @param int $id
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function deleteTeamById(int $id, SessionHandle $session): HttpResponse {
|
||||||
|
$a = $session->getAccount();
|
||||||
|
$ret = $this->model->deleteTeam($a->getUser()->getEmail(), $id);
|
||||||
|
if($ret != 0) {
|
||||||
|
return ViewHttpResponse::twig('display_team.html.twig', ['notDeleted' => true]);
|
||||||
|
}
|
||||||
|
return HttpResponse::redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a team with its id
|
||||||
|
* @param int $id
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse a view that displays given team information
|
||||||
|
*/
|
||||||
|
public function displayTeam(int $id, SessionHandle $session): ViewHttpResponse {
|
||||||
|
$result = $this->model->getTeam($id, $session->getAccount()->getUser()->getId());
|
||||||
|
if($result == null) {
|
||||||
|
return ViewHttpResponse::twig('error.html.twig', [
|
||||||
|
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette équipe.")],
|
||||||
|
], HttpCodes::FORBIDDEN);
|
||||||
|
}
|
||||||
|
$role = $this->model->isCoach($id, $session->getAccount()->getUser()->getEmail());
|
||||||
|
|
||||||
|
return ViewHttpResponse::react(
|
||||||
|
'views/TeamPanel.tsx',
|
||||||
|
[
|
||||||
|
'team' => [
|
||||||
|
"info" => $result->getInfo(),
|
||||||
|
"members" => $result->listMembers(),
|
||||||
|
],
|
||||||
|
'isCoach' => $role,
|
||||||
|
'currentUserId' => $session->getAccount()->getUser()->getId()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $idTeam
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse the team panel to add a member
|
||||||
|
*/
|
||||||
|
public function displayAddMember(int $idTeam, SessionHandle $session): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::twig("add_member.html.twig", ['idTeam' => $idTeam]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* add a member to a team
|
||||||
|
* @param int $idTeam
|
||||||
|
* @param array<string, mixed> $request
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function addMember(int $idTeam, array $request, SessionHandle $session): HttpResponse {
|
||||||
|
$errors = [];
|
||||||
|
if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) {
|
||||||
|
return ViewHttpResponse::twig('error.html.twig', [
|
||||||
|
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")],
|
||||||
|
], HttpCodes::FORBIDDEN);
|
||||||
|
}
|
||||||
|
$request = HttpRequest::from($request, $errors, [
|
||||||
|
"email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)],
|
||||||
|
]);
|
||||||
|
if(!empty($errors)) {
|
||||||
|
return ViewHttpResponse::twig('add_member.html.twig', ['badEmail' => true,'idTeam' => $idTeam]);
|
||||||
|
}
|
||||||
|
$ret = $this->model->addMember($request['email'], $idTeam, $request['role']);
|
||||||
|
|
||||||
|
switch($ret) {
|
||||||
|
case -1:
|
||||||
|
return ViewHttpResponse::twig('add_member.html.twig', ['notFound' => true,'idTeam' => $idTeam]);
|
||||||
|
case -2:
|
||||||
|
return ViewHttpResponse::twig('add_member.html.twig', ['alreadyExisting' => true,'idTeam' => $idTeam]);
|
||||||
|
default:
|
||||||
|
return HttpResponse::redirect('/team/' . $idTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* remove a member from a team with their ids
|
||||||
|
* @param int $idTeam
|
||||||
|
* @param int $idMember
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function deleteMember(int $idTeam, int $idMember, SessionHandle $session): HttpResponse {
|
||||||
|
if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) {
|
||||||
|
return ViewHttpResponse::twig('error.html.twig', [
|
||||||
|
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")],
|
||||||
|
], HttpCodes::FORBIDDEN);
|
||||||
|
}
|
||||||
|
$teamId = $this->model->deleteMember($idMember, $idTeam);
|
||||||
|
if($teamId == -1 || $session->getAccount()->getUser()->getId() == $idMember) {
|
||||||
|
return HttpResponse::redirect('/');
|
||||||
|
}
|
||||||
|
return $this->displayTeam($teamId, $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $idTeam
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse
|
||||||
|
*/
|
||||||
|
public function displayEditTeam(int $idTeam, SessionHandle $session): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::twig("edit_team.html.twig", ['team' => $this->model->getTeam($idTeam, $session->getAccount()->getUser()->getId())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $idTeam
|
||||||
|
* @param array<string,mixed> $request
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function editTeam(int $idTeam, array $request, SessionHandle $session): HttpResponse {
|
||||||
|
if(!$this->model->isCoach($idTeam, $session->getAccount()->getUser()->getEmail())) {
|
||||||
|
return ViewHttpResponse::twig('error.html.twig', [
|
||||||
|
'failures' => [ValidationFail::unauthorized("Vous n'avez pas accès à cette action pour cette équipe.")],
|
||||||
|
], HttpCodes::FORBIDDEN);
|
||||||
|
}
|
||||||
|
$failures = [];
|
||||||
|
$request = HttpRequest::from($request, $failures, [
|
||||||
|
"name" => [DefaultValidators::lenBetween(1, 32), DefaultValidators::nameWithSpaces()],
|
||||||
|
"main_color" => [DefaultValidators::hexColor()],
|
||||||
|
"second_color" => [DefaultValidators::hexColor()],
|
||||||
|
"picture" => [DefaultValidators::isURL()],
|
||||||
|
]);
|
||||||
|
if (!empty($failures)) {
|
||||||
|
$badFields = [];
|
||||||
|
foreach ($failures as $e) {
|
||||||
|
if ($e instanceof FieldValidationFail) {
|
||||||
|
$badFields[] = $e->getFieldName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ViewHttpResponse::twig('edit_team.html.twig', ['bad_fields' => $badFields]);
|
||||||
|
}
|
||||||
|
$this->model->editTeam($idTeam, $request['name'], $request['picture'], $request['main_color'], $request['second_color']);
|
||||||
|
return HttpResponse::redirect('/team/' . $idTeam);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\App\Controller;
|
||||||
|
|
||||||
|
use IQBall\App\Session\MutableSessionHandle;
|
||||||
|
use IQBall\App\Session\SessionHandle;
|
||||||
|
use IQBall\App\ViewHttpResponse;
|
||||||
|
use IQBall\Core\Http\HttpRequest;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Model\PersonalSpaceModel;
|
||||||
|
use IQBall\Core\Model\TacticModel;
|
||||||
|
use IQBall\Core\Model\TeamModel;
|
||||||
|
use IQBall\Core\Validation\DefaultValidators;
|
||||||
|
|
||||||
|
class UserController {
|
||||||
|
private TacticModel $tactics;
|
||||||
|
private ?TeamModel $teams;
|
||||||
|
private PersonalSpaceModel $personalSpace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TacticModel $tactics
|
||||||
|
* @param TeamModel|null $teams
|
||||||
|
* @param PersonalSpaceModel $personalSpace
|
||||||
|
*/
|
||||||
|
public function __construct(TacticModel $tactics, ?TeamModel $teams, PersonalSpaceModel $personalSpace) {
|
||||||
|
$this->tactics = $tactics;
|
||||||
|
$this->teams = $teams;
|
||||||
|
$this->personalSpace = $personalSpace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return ViewHttpResponse the home page view
|
||||||
|
*/
|
||||||
|
public function home(SessionHandle $session): ViewHttpResponse {
|
||||||
|
$rootFolder = 0;
|
||||||
|
$limitNbTactics = 5;
|
||||||
|
|
||||||
|
$user = $session->getAccount()->getUser();
|
||||||
|
|
||||||
|
$lastTactics = $this->tactics->getLast($limitNbTactics, $user->getId());
|
||||||
|
$rootTactics = $this->personalSpace->getTacticFromFolder($user->getId());
|
||||||
|
$rootFolders = $this->personalSpace->getFolderFromFolder($user->getId());
|
||||||
|
$name = $user->getName();
|
||||||
|
if ($this->teams != null) {
|
||||||
|
$teams = $this->teams->getAll($user->getId());
|
||||||
|
} else {
|
||||||
|
$teams = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ViewHttpResponse::react("views/Home.tsx", [
|
||||||
|
"lastTactics" => $lastTactics,
|
||||||
|
"allTactics" => $rootTactics,
|
||||||
|
"folders" => $rootFolders,
|
||||||
|
"teams" => $teams,
|
||||||
|
"username" => $name,
|
||||||
|
"currentFolder" => $rootFolder
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ViewHttpResponse account settings page
|
||||||
|
*/
|
||||||
|
public function settings(SessionHandle $session): ViewHttpResponse {
|
||||||
|
return ViewHttpResponse::react("views/Settings.tsx", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disconnect(MutableSessionHandle $session): HttpResponse {
|
||||||
|
$session->destroy();
|
||||||
|
return HttpResponse::redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createFolder(SessionHandle $session,array $request,int $idParentFolder): HttpResponse{
|
||||||
|
$this->personalSpace->createFolder($request['folderName'],$session->getAccount()->getUser()->getId(),$idParentFolder);
|
||||||
|
return HttpResponse::redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IQBall\App\Controller;
|
||||||
|
|
||||||
|
use IQBall\App\Session\SessionHandle;
|
||||||
|
use IQBall\App\Validator\TacticValidator;
|
||||||
|
use IQBall\App\ViewHttpResponse;
|
||||||
|
use IQBall\Core\Http\HttpCodes;
|
||||||
|
use IQBall\Core\Http\HttpResponse;
|
||||||
|
use IQBall\Core\Model\TacticModel;
|
||||||
|
|
||||||
|
class VisualizerController {
|
||||||
|
private TacticModel $tacticModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TacticModel $tacticModel
|
||||||
|
*/
|
||||||
|
public function __construct(TacticModel $tacticModel) {
|
||||||
|
$this->tacticModel = $tacticModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a visualisation page for the tactic specified by its identifier in the url.
|
||||||
|
* @param int $id
|
||||||
|
* @param SessionHandle $session
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function openVisualizer(int $id, SessionHandle $session): HttpResponse {
|
||||||
|
$tactic = $this->tacticModel->get($id);
|
||||||
|
|
||||||
|
$failure = TacticValidator::validateAccess($id, $tactic, $session->getAccount()->getUser()->getId());
|
||||||
|
|
||||||
|
if ($failure != null) {
|
||||||
|
return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ViewHttpResponse::react("views/Visualizer.tsx", ["name" => $tactic->getName()]);
|
||||||
|
}
|
||||||
|
}
|