Merge branch 'master' of codefirst.iut.uca.fr:IQBall/Application-Web into front-controller

pull/17/head
Override-6 1 year ago
commit 35d0370564
Signed by untrusted user who does not match committer: maxime.batista
GPG Key ID: 8002CC4B4DD9ECA5

2
.gitignore vendored

@ -38,3 +38,5 @@ package-lock.json
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.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);

@ -0,0 +1,7 @@
{
"bracketSameLine": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 4,
"semi": false
}

@ -1,12 +0,0 @@
@startuml
class Connexion
class Modele
class Account
class AccountGateway
@enduml

@ -50,7 +50,6 @@ enum MemberRole {
class Team {
- name: String
- picture: Url
- members: array<int, MemberRole>
+ getName(): String
+ getPicture(): Url
@ -61,6 +60,7 @@ class Team {
Team --> "- mainColor" Color
Team --> "- secondaryColor" Color
Team --> "- members *" Member
class Color {
- value: int
@ -68,4 +68,30 @@ class Color {
+ getValue(): int
}
class AuthController{
+ displayRegister() : HttpResponse
+ displayBadFields(viewName : string, fails : array) : HttpResponse
+ confirmRegister(request : array) : HttpResponse
+ displayLogin() : HttpResponse
+ confirmLogin() : HttpResponse
}
AuthController --> "- model" AuthModel
class AuthModel{
+ register(username : string, password : string, confirmPassword : string, email : string): array
+ getUserFields(email : string):array
+ login(email : string, password : string)
}
AuthModel --> "- gateway" AuthGateway
class AuthGateway{
-con : Connection
+ mailExist(email : string) : bool
+ insertAccount(username : string, hash : string, email : string)
+ getUserHash(email : string):string
+ getUserFields (email : string): array
}
@enduml

@ -1,6 +1,6 @@
kind: pipeline
type: docker
name: "Deploy on maxou.dev"
name: "CI and Deploy on maxou.dev"
volumes:
- name: server
@ -11,11 +11,26 @@ trigger:
- push
steps:
- image: node:latest
name: "front CI"
commands:
- npm install
- npm run tsc
- image: composer:latest
name: "php CI"
commands:
- composer install
- vendor/bin/phpstan analyze
- image: node:latest
name: "build node"
volumes: &outputs
- name: server
path: /outputs
depends_on:
- "front CI"
commands:
- curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh
- chmod +x /tmp/moshell_setup.sh
@ -24,14 +39,15 @@ steps:
-
- /root/.local/bin/moshell ci/build_react.msh
- image: composer:latest
- image: ubuntu:latest
name: "prepare php"
volumes: *outputs
depends_on:
- "php CI"
commands:
- mkdir -p /outputs/public
# this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file.
- sed -iE 's/\\/\\*PROFILE_FILE\\*\\/\\s*".*"/"profiles\\/prod-config-profile.php"/' config.php
- composer install && composer update
- rm profiles/dev-config-profile.php
- mv src config.php sql profiles vendor /outputs/

@ -3,7 +3,12 @@
mkdir -p /outputs/public
apt update && apt install jq -y
npm install
val drone_branch = std::env("DRONE_BRANCH").unwrap()
val base = "/IQBall/$drone_branch/public"
npm run build -- --base=$base --mode PROD
npm run build -- --base=/IQBall/public --mode PROD
// Read generated mappings from build

@ -9,7 +9,10 @@
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"twig/twig":"^2.0"
"twig/twig":"^2.0",
"phpstan/phpstan": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.38"
}
}
}

@ -5,7 +5,7 @@
// Please do not touch.
require /*PROFILE_FILE*/ "profiles/dev-config-profile.php";
CONST SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH;
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.
@ -20,4 +20,3 @@ 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

@ -1,4 +1,4 @@
/**
* This constant defines the API endpoint.
*/
export const API = import.meta.env.VITE_API_ENDPOINT;
export const API = import.meta.env.VITE_API_ENDPOINT

@ -1,5 +1,5 @@
import ReactDOM from "react-dom/client";
import React, {FunctionComponent} from "react";
import ReactDOM from "react-dom/client"
import React, { FunctionComponent } from "react"
/**
* Dynamically renders a React component, with given arguments
@ -8,14 +8,12 @@ import React, {FunctionComponent} from "react";
*/
export function renderView(Component: FunctionComponent, args: {}) {
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
console.log(args)
document.getElementById("root") as HTMLElement,
)
root.render(
<React.StrictMode>
<Component {...args}/>
</React.StrictMode>
);
}
<Component {...args} />
</React.StrictMode>,
)
}

@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="100%"
viewBox="7.5 18.5 85.5 56"
style="enable-background:new 7.5 18.5 85.5 56;"
xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4358,1.4358;}
.st2{fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:10;}
.st3{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4407,1.4407;}
</style>
<polygon class="st0" points="92.1,72.1 50.1,72.1 8.1,72.1 8.1,21.2 50.1,21.2 92.1,21.2 "/>
<line class="st0" x1="50.1" y1="21.2" x2="50.1" y2="72.1"/>
<circle class="st0" cx="50.1" cy="46.6" r="6.4"/>
<path class="st0" d="M8.1,66h7.2c10.1,0,18.2-8.7,18.2-19.3s-8.2-19.3-18.2-19.3H8.1"/>
<path class="st0" d="M8.1,40.2h19c3.6,0,6.4,2.9,6.4,6.4s-2.9,6.4-6.4,6.4h-19"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<g>
<g><path class="st0" d="M27.4,40.3c-0.3,0-0.5,0-0.7,0"/>
<path class="st1"
d="M25.3,40.7c-2.5,0.9-4.3,3.3-4.3,6.1c0,3,2.2,5.6,5,6.2"/>
<path
class="st0" d="M26.7,53c0.2,0,0.5,0,0.7,0"/>
</g>
</g>
<line class="st0" x1="16.2" y1="53.1" x2="16.2" y2="54.1"/>
<line class="st2" x1="19.3" y1="53.1" x2="19.3" y2="54.1"/>
<line class="st2" x1="22.4" y1="53.1" x2="22.4" y2="54.1"/>
<line class="st2" x1="25.7" y1="53.1" x2="25.7" y2="54.1"/>
<line class="st0" x1="16.1" y1="39.2" x2="16.1" y2="40.2"/>
<line class="st2" x1="19.2" y1="39.2" x2="19.2" y2="40.2"/>
<line class="st2" x1="22.3" y1="39.2" x2="22.3" y2="40.2"/>
<line class="st2" x1="25.6" y1="39.2" x2="25.6" y2="40.2"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<path class="st0" d="M92.1,66.1h-7.2c-10.1,0-18.2-8.7-18.2-19.3s8.2-19.3,18.2-19.3h7.2"/>
<path class="st0" d="M92.1,40.3h-19c-3.6,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4h19"/>
<line class="st0" x1="84" y1="53.2" x2="84" y2="54.1"/>
<line class="st2" x1="80.9" y1="53.2" x2="80.9" y2="54.1"/>
<line class="st2" x1="77.9" y1="53.2" x2="77.9" y2="54.1"/>
<line class="st2" x1="74.5" y1="53.2" x2="74.5" y2="54.1"/>
<line class="st0" x1="84.1" y1="39.3" x2="84.1" y2="40.3"/>
<line class="st2" x1="81" y1="39.3" x2="81" y2="40.3"/>
<line class="st2" x1="77.9" y1="39.3" x2="77.9" y2="40.3"/>
<line class="st2" x1="74.6" y1="39.3" x2="74.6" y2="40.3"/>
<line class="st0" x1="73.1" y1="40.3" x2="73.1" y2="53.2"/>
<line class="st2" x1="36.2" y1="70" x2="36.2" y2="74.1"/>
<line class="st2" x1="63.5" y1="70" x2="63.5" y2="74.1"/>
<line class="st2" x1="36.2" y1="19.1" x2="36.2" y2="23.2"/>
<line class="st2" x1="63.5" y1="19.1" x2="63.5" y2="23.2"/>
<g xmlns="http://www.w3.org/2000/svg">
<g>
<path class="st0" d="M72.9,40.3c0.3,0,0.5,0,0.7,0"/>
<path class="st3"
d="M75,40.7c2.5,0.9,4.3,3.3,4.3,6.1c0,3-2.2,5.6-5.1,6.2"/>
<path
class="st0" d="M73.5,53.1c-0.2,0-0.5,0-0.7,0"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@ -0,0 +1,5 @@
<svg width="80" height="49" viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

@ -0,0 +1,72 @@
import { ReactElement, useRef } from "react"
import Draggable from "react-draggable"
export interface RackProps<E extends { key: string | number }> {
id: string
objects: E[]
onChange: (objects: E[]) => void
canDetach: (ref: HTMLDivElement) => boolean
onElementDetached: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
}
interface RackItemProps<E extends { key: string | number }> {
item: E
onTryDetach: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
}
/**
* A container of draggable objects
* */
export function Rack<E extends { key: string | number }>({
id,
objects,
onChange,
canDetach,
onElementDetached,
render,
}: RackProps<E>) {
return (
<div
id={id}
style={{
display: "flex",
}}>
{objects.map((element) => (
<RackItem
key={element.key}
item={element}
render={render}
onTryDetach={(ref, element) => {
if (!canDetach(ref)) return
const index = objects.findIndex(
(o) => o.key === element.key,
)
onChange(objects.toSpliced(index, 1))
onElementDetached(ref, element)
}}
/>
))}
</div>
)
}
function RackItem<E extends { key: string | number }>({
item,
onTryDetach,
render,
}: RackItemProps<E>) {
const divRef = useRef<HTMLDivElement>(null)
return (
<Draggable
position={{ x: 0, y: 0 }}
nodeRef={divRef}
onStop={() => onTryDetach(divRef.current!, item)}>
<div ref={divRef}>{render(item)}</div>
</Draggable>
)
}

@ -1,28 +1,32 @@
import React, {CSSProperties, useRef, useState} from "react";
import "../style/title_input.css";
import React, { CSSProperties, useRef, useState } from "react"
import "../style/title_input.css"
export interface TitleInputOptions {
style: CSSProperties,
default_value: string,
style: CSSProperties
default_value: string
on_validated: (a: string) => void
}
export default function TitleInput({style, default_value, on_validated}: TitleInputOptions) {
const [value, setValue] = useState(default_value);
const ref = useRef<HTMLInputElement>(null);
export default function TitleInput({
style,
default_value,
on_validated,
}: TitleInputOptions) {
const [value, setValue] = useState(default_value)
const ref = useRef<HTMLInputElement>(null)
return (
<input className="title_input"
ref={ref}
style={style}
type="text"
value={value}
onChange={event => setValue(event.target.value)}
onBlur={_ => on_validated(value)}
onKeyDown={event => {
if (event.key == 'Enter')
ref.current?.blur();
}}
<input
className="title_input"
ref={ref}
style={style}
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={(_) => on_validated(value)}
onKeyDown={(event) => {
if (event.key == "Enter") ref.current?.blur()
}}
/>
)
}
}

@ -0,0 +1,27 @@
import CourtSvg from "../../assets/basketball_court.svg?react"
import "../../style/basket_court.css"
import { useRef } from "react"
import CourtPlayer from "./CourtPlayer"
import { Player } from "../../data/Player"
export interface BasketCourtProps {
players: Player[]
onPlayerRemove: (p: Player) => void
}
export function BasketCourt({ players, onPlayerRemove }: BasketCourtProps) {
return (
<div id="court-container" style={{ position: "relative" }}>
<CourtSvg id="court-svg" />
{players.map((player) => {
return (
<CourtPlayer
key={player.id}
player={player}
onRemove={() => onPlayerRemove(player)}
/>
)
})}
</div>
)
}

@ -0,0 +1,49 @@
import { useRef } from "react"
import "../../style/player.css"
import RemoveIcon from "../../assets/icon/remove.svg?react"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../data/Player"
export interface PlayerProps {
player: Player
onRemove: () => void
}
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({ player, onRemove }: PlayerProps) {
const ref = useRef<HTMLDivElement>(null)
const x = player.rightRatio
const y = player.bottomRatio
return (
<Draggable handle={".player-piece"} nodeRef={ref} bounds="parent">
<div
ref={ref}
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-selection-tab">
<RemoveIcon
className="player-selection-tab-remove"
onClick={onRemove}
/>
</div>
<PlayerPiece team={player.team} text={player.role} />
</div>
</div>
</Draggable>
)
}

@ -0,0 +1,11 @@
import React from "react"
import "../../style/player.css"
import { Team } from "../../data/Team"
export function PlayerPiece({ team, text }: { team: Team; text: string }) {
return (
<div className={`player-piece ${team}`}>
<p>{text}</p>
</div>
)
}

@ -0,0 +1,29 @@
import { Team } from "./Team"
export interface Player {
/**
* unique identifier of the player.
* This identifier must be unique to the associated court.
*/
id: number
/**
* the player's team
* */
team: Team
/**
* player's position
* */
role: string
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
rightRatio: number
}

@ -0,0 +1,4 @@
export enum Team {
Allies = "allies",
Opponents = "opponents",
}

@ -0,0 +1,15 @@
#court-container {
display: flex;
background-color: var(--main-color);
}
#court-svg {
margin: 5%;
user-select: none;
-webkit-user-drag: none;
}
#court-svg * {
stroke: var(--selected-team-secondarycolor);
}

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

@ -1,13 +1,15 @@
@import "colors.css";
#main {
#main-div {
display: flex;
height: 100%;
width: 100%;
background-color: var(--background-color);
flex-direction: column;
}
#topbar {
#topbar-div {
display: flex;
background-color: var(--main-color);
@ -15,6 +17,42 @@
align-items: stretch;
}
#racks {
display: flex;
justify-content: space-between;
}
.title_input {
width: 25ch;
}
}
#edit-div {
height: 100%;
}
#allies-rack .player-piece,
#opponent-rack .player-piece {
margin-left: 5px;
}
.player-piece.opponents {
background-color: #f59264;
}
#court-div {
background-color: var(--background-color);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
align-content: center;
}
#court-div-bounds {
width: 60%;
}
.react-draggable {
z-index: 2;
}

@ -0,0 +1,79 @@
/**
as the .player div content is translated,
the real .player div position is not were the user can expect.
Disable pointer events to this div as it may overlap on other components
on the court.
*/
.player {
pointer-events: none;
}
.player-content {
display: flex;
flex-direction: column;
align-content: center;
align-items: center;
outline: none;
}
.player-piece {
font-family: monospace;
pointer-events: all;
background-color: var(--selected-team-primarycolor);
color: var(--selected-team-secondarycolor);
border-width: 2px;
border-radius: 100px;
border-style: solid;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.player-selection-tab {
display: flex;
position: absolute;
margin-bottom: 10%;
justify-content: center;
visibility: hidden;
width: 100%;
transform: translateY(-20px);
}
.player-selection-tab-remove {
pointer-events: all;
height: 25%;
}
.player-selection-tab-remove * {
stroke: red;
fill: white;
}
.player-selection-tab-remove:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}
.player:focus-within .player-selection-tab {
visibility: visible;
}
.player:focus-within .player-piece {
color: var(--selection-color);
}
.player:focus-within {
z-index: 1000;
}

@ -14,4 +14,3 @@
border-bottom-color: blueviolet;
}

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

@ -1,19 +1,13 @@
interface DisplayResultsProps {
results: readonly { name: string, description: string}[]
results: readonly { name: string; description: string }[]
}
export default function DisplayResults({results}: DisplayResultsProps) {
const list = results
.map(({name, description}) =>
export default function DisplayResults({ results }: DisplayResultsProps) {
const list = results.map(({ name, description }) => (
<div>
<p>username: {name}</p>
<p>description: {description}</p>
</div>
)
return (
<div>
{list}
</div>
)
))
return <div>{list}</div>
}

@ -1,41 +1,162 @@
import React, {CSSProperties, useState} from "react";
import "../style/editor.css";
import TitleInput from "../components/TitleInput";
import {API} from "../Constants";
import { CSSProperties, useRef, useState } from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import { API } from "../Constants"
import { BasketCourt } from "../components/editor/BasketCourt"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import { Player } from "../data/Player"
import { Team } from "../data/Team"
const ERROR_STYLE: CSSProperties = {
borderColor: "red"
borderColor: "red",
}
/**
* information about a player that is into a rack
*/
interface RackedPlayer {
team: Team
key: string
}
export default function Editor({id, name}: { id: number, name: string }) {
export default function Editor({ id, name }: { id: number; name: string }) {
const [style, setStyle] = useState<CSSProperties>({})
const positions = ["1", "2", "3", "4", "5"]
const [allies, setAllies] = useState(
positions.map((key) => ({ team: Team.Allies, key })),
)
const [opponents, setOpponents] = useState(
positions.map((key) => ({ team: Team.Opponents, key })),
)
const [players, setPlayers] = useState<Player[]>([])
const courtDivContentRef = useRef<HTMLDivElement>(null)
const canDetach = (ref: HTMLDivElement) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const [style, setStyle] = useState<CSSProperties>({});
// check if refBounds overlaps courtBounds
return !(
refBounds.top > courtBounds.bottom ||
refBounds.right < courtBounds.left ||
refBounds.bottom < courtBounds.top ||
refBounds.left > courtBounds.right
)
}
const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const relativeXPixels = refBounds.x - courtBounds.x
const relativeYPixels = refBounds.y - courtBounds.y
const xRatio = relativeXPixels / courtBounds.width
const yRatio = relativeYPixels / courtBounds.height
setPlayers((players) => {
return [
...players,
{
id: players.length,
team: element.team,
role: element.key,
rightRatio: xRatio,
bottomRatio: yRatio,
},
]
})
}
return (
<div id="main">
<div id="topbar">
<div id="main-div">
<div id="topbar-div">
<div>LEFT</div>
<TitleInput style={style} default_value={name} on_validated={new_name => {
fetch(`${API}/tactic/${id}/edit/name`, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: new_name,
<TitleInput
style={style}
default_value={name}
on_validated={(new_name) => {
fetch(`${API}/tactic/${id}/edit/name`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: new_name,
}),
}).then((response) => {
if (response.ok) {
setStyle({})
} else {
setStyle(ERROR_STYLE)
}
})
}).then(response => {
if (response.ok) {
setStyle({})
} else {
setStyle(ERROR_STYLE)
}
})
}}/>
}}
/>
<div>RIGHT</div>
</div>
<div id="edit-div">
<div id="racks">
<Rack
id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
)}
/>
<Rack
id="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
)}
/>
</div>
<div id="court-div">
<div id="court-div-bounds" ref={courtDivContentRef}>
<BasketCourt
players={players}
onPlayerRemove={(player) => {
setPlayers((players) => {
const idx = players.indexOf(player)
return players.toSpliced(idx, 1)
})
switch (player.team) {
case Team.Opponents:
setOpponents((opponents) => [
...opponents,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
break
case Team.Allies:
setAllies((allies) => [
...allies,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
}
}}
/>
</div>
</div>
</div>
</div>
)
}

@ -1,19 +1,14 @@
export default function SampleForm() {
return (
<div>
<h1>Hello, this is a sample form made in react !</h1>
<form action="submit" method="POST">
<label>your name: </label>
<input type="text" id="name" name="name"/>
<input type="text" id="name" name="name" />
<label>a little description about yourself: </label>
<input type="text" id="password" name="description"/>
<input type="submit" value="click me to submit!"/>
<input type="text" id="password" name="description" />
<input type="submit" value="click me to submit!" />
</form>
</div>
)
}

@ -0,0 +1,23 @@
import React, { CSSProperties, useState } from "react"
import "../style/visualizer.css"
import Court from "../assets/basketball_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>
)
}

@ -12,15 +12,17 @@
"@types/react-dom": "^18.2.14",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0",
"web-vitals": "^2.1.4"
"vite-plugin-css-injected-by-js": "^3.3.0"
},
"scripts": {
"start": "vite --host",
"build": "vite build",
"test": "vite test"
"test": "vite test",
"format": "prettier --config .prettierrc 'front' --write",
"tsc": "tsc"
},
"eslintConfig": {
"extends": [
@ -29,6 +31,9 @@
]
},
"devDependencies": {
"@vitejs/plugin-react": "^4.1.0"
"@vitejs/plugin-react": "^4.1.0",
"vite-plugin-svgr": "^4.1.0",
"prettier": "^3.1.0",
"typescript": "^5.2.2"
}
}

@ -0,0 +1,13 @@
parameters:
phpVersion: 70400
level: 6
paths:
- src
- public
scanFiles:
- config.php
- sql/database.php
- profiles/dev-config-profile.php
- profiles/prod-config-profile.php
excludePaths:
- src/react-display-file.php

@ -10,11 +10,7 @@ $_data_source_name = "sqlite:${_SERVER['DOCUMENT_ROOT']}/../dev-database.sqlite"
const _DATABASE_USER = null;
const _DATABASE_PASSWORD = null;
function _asset(string $assetURI): string
{
function _asset(string $assetURI): string {
global $front_url;
return $front_url . "/" . $assetURI;
}

@ -19,4 +19,4 @@ function _asset(string $assetURI): string {
// If the asset uri does not figure in the available assets array,
// fallback to the uri itself.
return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI);
}
}

@ -37,6 +37,6 @@ http_response_code($response->getCode());
if ($response instanceof JsonHttpResponse) {
header('Content-type: application/json');
echo $response->getJson();
} else if ($response instanceof ViewHttpResponse) {
} elseif ($response instanceof ViewHttpResponse) {
throw new Exception("API returned a view http response.");
}
}

@ -0,0 +1 @@
../front

@ -16,4 +16,5 @@ $basePath = get_public_path();
$frontController = new FrontController($basePath);
$frontController->run();
$frontController->run();

@ -3,18 +3,19 @@
/**
* relative path of the public directory from the server's document root.
*/
function get_public_path() {
function get_public_path(): string {
// find the server path of the index.php file
$basePath = substr(__DIR__, strlen($_SERVER['DOCUMENT_ROOT']));
$basePathLen = strlen($basePath);
if ($basePathLen == 0)
if ($basePathLen == 0) {
return "";
}
$c = $basePath[$basePathLen - 1];
if ($c == "/" || $c == "\\") {
$basePath = substr($basePath, 0, $basePathLen - 1);
}
return $basePath;
}
}

@ -25,6 +25,3 @@ function get_database(): PDO {
return $pdo;
}

@ -1,9 +1,15 @@
-- drop tables here
DROP TABLE IF EXISTS FormEntries;
DROP TABLE IF EXISTS AccountUser;
DROP TABLE IF EXISTS TacticInfo;
CREATE TABLE FormEntries(name varchar, description varchar);
CREATE TABLE AccountUser(
username varchar,
hash varchar,
email varchar unique
);
CREATE TABLE TacticInfo(
id integer PRIMARY KEY AUTOINCREMENT,

@ -1,28 +1,27 @@
<?php
namespace App;
use \PDO;
class Connexion {
use PDO;
class Connexion {
private PDO $pdo;
/**
* @param PDO $pdo
*/
public function __construct(PDO $pdo)
{
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function lastInsertId() {
public function lastInsertId(): string {
return $this->pdo->lastInsertId();
}
/**
* execute a request
* @param string $query
* @param array $args
* @param array<string, array<mixed, int>> $args
* @return void
*/
public function exec(string $query, array $args) {
@ -33,8 +32,8 @@ class Connexion {
/**
* Execute a request, and return the returned rows
* @param string $query the SQL request
* @param array $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]`
* @return array the returned rows of the request
* @param array<string, array<mixed, int>> $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]`
* @return array<string, mixed>[] the returned rows of the request
*/
public function fetch(string $query, array $args): array {
$stmnt = $this->prepare($query, $args);
@ -42,6 +41,11 @@ class Connexion {
return $stmnt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @param string $query
* @param array<string, array<mixed, int>> $args
* @return \PDOStatement
*/
private function prepare(string $query, array $args): \PDOStatement {
$stmnt = $this->pdo->prepare($query);
foreach ($args as $name => $value) {
@ -50,4 +54,4 @@ class Connexion {
return $stmnt;
}
}
}

@ -25,21 +25,21 @@ class APITacticController {
public function updateName(int $tactic_id): HttpResponse {
return Control::runChecked([
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()]
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()],
], function (HttpRequest $request) use ($tactic_id) {
$this->model->updateName($tactic_id, $request["name"]);
return HttpResponse::fromCode(HttpCodes::OK);
});
}, true);
}
public function newTactic(): HttpResponse {
return Control::runChecked([
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()]
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()],
], function (HttpRequest $request) {
$tactic = $this->model->makeNew($request["name"]);
$id = $tactic->getId();
return new JsonHttpResponse(["id" => $id]);
});
}, true);
}
public function getTacticInfo(int $id): HttpResponse {
@ -52,4 +52,4 @@ class APITacticController {
return new JsonHttpResponse($tactic_info);
}
}
}

@ -0,0 +1,94 @@
<?php
namespace App\Controller;
use App\Gateway\AuthGateway;
use App\Http\HttpRequest;
use App\Http\HttpResponse;
use App\Http\ViewHttpResponse;
use App\Model\AuthModel;
use App\Validation\FieldValidationFail;
use App\Validation\ValidationFail;
use App\Validation\Validators;
use Twig\Environment;
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", []);
}
/**
* @param string $viewName
* @param ValidationFail[] $fails
* @return HttpResponse
*/
private function displayBadFields(string $viewName, array $fails): HttpResponse {
$bad_fields = [];
foreach ($fails as $err) {
if ($err instanceof FieldValidationFail) {
$bad_fields[] = $err->getFieldName();
}
}
return ViewHttpResponse::twig($viewName, ['bad_fields' => $bad_fields]);
}
/**
* @param mixed[] $request
* @return HttpResponse
*/
public function confirmRegister(array $request): HttpResponse {
$fails = [];
$request = HttpRequest::from($request, $fails, [
"username" => [Validators::name(), Validators::lenBetween(2, 32)],
"password" => [Validators::lenBetween(6, 256)],
"confirmpassword" => [Validators::lenBetween(6, 256)],
"email" => [Validators::regex("/^\\S+@\\S+\\.\\S+$/"),Validators::lenBetween(5, 256)],
]);
if (!empty($fails)) {
return $this->displayBadFields("display_register.html.twig", $fails);
}
$fails = $this->model->register($request['username'], $request["password"], $request['confirmpassword'], $request['email']);
if (empty($fails)) {
$results = $this->model->getUserFields($request['email']);
return ViewHttpResponse::twig("display_auth_confirm.html.twig", ['username' => $results['username'], 'email' => $results['email']]);
}
return $this->displayBadFields("display_register.html.twig", $fails);
}
public function displayLogin(): HttpResponse {
return ViewHttpResponse::twig("display_login.html.twig", []);
}
/**
* @param mixed[] $request
* @return HttpResponse
*/
public function confirmLogin(array $request): HttpResponse {
$fails = [];
$request = HttpRequest::from($request, $fails, [
"password" => [Validators::lenBetween(6, 256)],
"email" => [Validators::regex("/^\\S+@\\S+\\.\\S+$/"),Validators::lenBetween(5, 256)],
]);
if (!empty($fails)) {
return $this->displayBadFields("display_login.html.twig", $fails);
}
$fails = $this->model->login($request['email'], $request['password']);
if (empty($fails)) {
$results = $this->model->getUserFields($request['email']);
return ViewHttpResponse::twig("display_auth_confirm.html.twig", ['username' => $results['username'], 'email' => $results['email']]);
}
return $this->displayBadFields("display_login.html.twig", $fails);
}
}

@ -8,14 +8,16 @@ use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Validation\ValidationFail;
use App\Validation\Validator;
class Control {
/**
* Runs given callback, if the request's json validates the given schema.
* @param array $schema an array of `fieldName => Validators` which represents the request object schema
* @param callable $run the callback to run if the request is valid according to the given schema.
* @param array<string, Validator[]> $schema an array of `fieldName => Validators` 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.
* @param bool $errorInJson if set to true, the returned response, in case of errors, will be a JsonHttpResponse, instead
* of the ViewHttpResponse for an error view.
* @return HttpResponse
*/
public static function runChecked(array $schema, callable $run, bool $errorInJson): HttpResponse {
@ -23,7 +25,7 @@ class Control {
$payload_obj = json_decode($request_body);
if (!$payload_obj instanceof \stdClass) {
$fail = new ValidationFail("bad-payload", "request body is not a valid json object");
if($errorInJson) {
if ($errorInJson) {
return new JsonHttpResponse([$fail, HttpCodes::BAD_REQUEST]);
}
return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST);
@ -34,10 +36,12 @@ class Control {
/**
* Runs given callback, if the given request data array validates the given schema.
* @param array $data the request's data array.
* @param array $schema an array of `fieldName => Validators` which represents the request object schema
* @param callable $run the callback to run if the request is valid according to the given schema.
* @param array<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $schema an array of `fieldName => Validators` 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.
* @param bool $errorInJson if set to true, the returned response, in case of errors, will be a JsonHttpResponse, instead
* of the ViewHttpResponse for an error view.
* @return HttpResponse
*/
public static function runCheckedFrom(array $data, array $schema, callable $run, bool $errorInJson): HttpResponse {
@ -45,7 +49,7 @@ class Control {
$request = HttpRequest::from($data, $fails, $schema);
if (!empty($fails)) {
if($errorInJson) {
if ($errorInJson) {
return new JsonHttpResponse($fails, HttpCodes::BAD_REQUEST);
}
return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST);
@ -53,9 +57,6 @@ class Control {
return call_user_func_array($run, [$request]);
}
}
}

@ -13,7 +13,6 @@ use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
class EditorController {
private TacticModel $model;
/**
@ -47,4 +46,4 @@ class EditorController {
return $this->openEditor($tactic);
}
}
}

@ -3,17 +3,23 @@
namespace App\Controller;
require_once __DIR__ . "/../react-display.php";
use \Twig\Environment;
use App\Validation\ValidationFail;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
class ErrorController
{
public static function displayFailures(array $failures, Environment $twig) {
class ErrorController {
/**
* @param ValidationFail[] $failures
* @param Environment $twig
* @return void
*/
public static function displayFailures(array $failures, Environment $twig): void {
try {
$twig->display("error.html.twig", ['failures' => $failures]);
} catch (LoaderError | RuntimeError | SyntaxError $e) {
} catch (LoaderError|RuntimeError|SyntaxError $e) {
echo "Twig error: $e";
}
}

@ -2,35 +2,23 @@
namespace App\Controller;
use App\Controller;
use App\Connexion;
use App\Controller\UserController;
use AltoRouter;
use App\Controller\ErrorController;
use App\Gateway\FormResultGateway;
use App\Gateway\TacticInfoGateway;
use App\Http\HttpCodes;
use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
use App\Validation\ValidationFail;
use Exception;
use Twig\Loader\FilesystemLoader;
class FrontController{
class FrontController {
private AltoRouter $router;
private array $dictControllerRole;
public function __construct(string $basePath) {
$this->router = $this->createRouter($basePath);
$this->dictControllerRole = [
"UserController" => "public",
"EditorController" => "public"
];
$this->initializeRouterMap();
}
/**
@ -38,11 +26,11 @@ class FrontController{
*
* @return void
*/
public function run() : void {
public function run(): void {
$this->initializeRouterMap();
$match = $this->router->match();
if ($match != null){
$match = $this->router->match();
if ($match != null) {
$this->handleMatch($match);
} else {
$this->diplayViewByKind(ViewHttpResponse::twig("error.html.twig", [], HttpCodes::NOT_FOUND));
@ -57,7 +45,7 @@ class FrontController{
* @param string $basePath
* @return AltoRouter
*/
public function createRouter(string $basePath) : AltoRouter {
public function createRouter(string $basePath): AltoRouter {
$router = new AltoRouter();
$router->setBasePath($basePath);
return $router;
@ -68,7 +56,7 @@ class FrontController{
*
* @return void
*/
private function initializeRouterMap() : void {
private function initializeRouterMap(): void {
$this->router->map("GET", "/", "UserController");
$this->router->map("GET", "/[a:action]?", "UserController");
$this->router->map("GET", "/tactic/[a:action]/[i:idTactic]?", "EditorController");
@ -79,11 +67,11 @@ class FrontController{
}
/**
* Call
* Call
*
* @return ViewHttpResponse
*/
private function handleMatch($match){
private function handleMatch($match) {
$tag = $match['target'];
$action = $this->getAction($match);
@ -94,29 +82,28 @@ class FrontController{
// foreach ($key, $value : )
// }
private function tryToCall($controller, $action, array $params){
private function tryToCall($controller, $action, array $params) {
unset($params["action"]);
$controller = $this->initControllerByRole($controller);
try {
if (is_callable(array($controller, $action))){
if (is_callable(array($controller, $action))) {
return call_user_func_array(array($controller, $action), $params);
} else {
return ViewHttpResponse::twig("error.html.twig", [], HttpCodes::NOT_FOUND);
}
}
catch (Exception $e) {
} catch (Exception $e) {
return ViewHttpResponse::twig("error.html.twig", [], HttpCodes::NOT_FOUND);
}
}
/**
* Get the right method to call to do an action
*
* @param array $match
* @return string
*/
private function getAction(array $match) : string {
if (isset($match["params"]["action"])){
private function getAction(array $match): string {
if (isset($match["params"]["action"])) {
return $match["params"]["action"];
}
return "home";
@ -129,14 +116,14 @@ class FrontController{
* @return void
*/
private function initControllerByRole(string $controller) {
$index = $controller;
$namespace = "\\App\\Controller\\";
$controller = $namespace.$controller;
$controller = $namespace . $controller;
if (isset($_SESSION['role'])){
if ($_SESSION['role'] == $this->dictControllerRole[$index]){
if (isset($_SESSION['role'])) {
if ($_SESSION['role'] == $this->dictControllerRole[$index]) {
$controller = new $controller();
return $controller;
}
@ -160,7 +147,7 @@ class FrontController{
* @param array $match
* @return void
*/
private function handleResponseByType(HttpResponse $response) : void {
private function handleResponseByType(HttpResponse $response): void {
// $response = call_user_func_array($match['target'], $match['params']);
http_response_code($response->getCode());
if ($response instanceof ViewHttpResponse) {
@ -179,7 +166,7 @@ class FrontController{
* @param ViewHttpResponse $response
* @return void
*/
private function diplayViewByKind(ViewHttpResponse $response) : void {
private function diplayViewByKind(ViewHttpResponse $response): void {
$file = $response->getFile();
$args = $response->getArguments();
@ -192,12 +179,12 @@ class FrontController{
$loader = new FilesystemLoader('../src/Views/');
$twig = new \Twig\Environment($loader);
$twig->display($file, $args);
} catch (\Twig\Error\RuntimeError | \Twig\Error\SyntaxError $e) {
} catch (\Twig\Error\RuntimeError|\Twig\Error\SyntaxError $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;
break;
}
}
}

@ -0,0 +1,64 @@
<?php
namespace App\Controller;
require_once __DIR__ . "/../react-display.php";
use App\Gateway\FormResultGateway;
use App\Http\HttpRequest;
use App\Http\HttpResponse;
use App\Http\ViewHttpResponse;
use App\Validation\Validators;
class SampleFormController {
private FormResultGateway $gateway;
/**
* @param FormResultGateway $gateway
*/
public function __construct(FormResultGateway $gateway) {
$this->gateway = $gateway;
}
public function displayFormReact(): HttpResponse {
return ViewHttpResponse::react("views/SampleForm.tsx", []);
}
public function displayFormTwig(): HttpResponse {
return ViewHttpResponse::twig('sample_form.html.twig', []);
}
/**
* @param array<string, mixed> $form
* @param callable(array<array<string, string>>): ViewHttpResponse $response
* @return HttpResponse
*/
private function submitForm(array $form, callable $response): HttpResponse {
return Control::runCheckedFrom($form, [
"name" => [Validators::lenBetween(0, 32), Validators::name("Le nom ne peut contenir que des lettres, des chiffres et des accents")],
"description" => [Validators::lenBetween(0, 512)],
], function (HttpRequest $req) use ($response) {
$description = htmlspecialchars($req["description"]);
$this->gateway->insert($req["name"], $description);
$results = ["results" => $this->gateway->listResults()];
return call_user_func_array($response, [$results]);
}, false);
}
/**
* @param array<string, mixed> $form
* @return HttpResponse
*/
public function submitFormTwig(array $form): HttpResponse {
return $this->submitForm($form, fn(array $results) => ViewHttpResponse::twig('display_results.html.twig', $results));
}
/**
* @param array<string, mixed> $form
* @return HttpResponse
*/
public function submitFormReact(array $form): HttpResponse {
return $this->submitForm($form, fn(array $results) => ViewHttpResponse::react('views/DisplayResults.tsx', $results));
}
}

@ -0,0 +1,32 @@
<?php
namespace App\Controller;
use App\Http\HttpCodes;
use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
class VisualizerController {
private TacticModel $tacticModel;
/**
* @param TacticModel $tacticModel
*/
public function __construct(TacticModel $tacticModel) {
$this->tacticModel = $tacticModel;
}
public function openVisualizer(int $id): HttpResponse {
$tactic = $this->tacticModel->get($id);
if ($tactic == null) {
return new JsonHttpResponse("la tactique " . $id . " n'existe pas", HttpCodes::NOT_FOUND);
}
return ViewHttpResponse::react("views/Visualizer.tsx", ["name" => $tactic->getName()]);
}
}

@ -4,7 +4,7 @@ namespace App\Data;
use http\Exception\InvalidArgumentException;
const PHONE_NUMBER_REGEXP = "\\+[0-9]+";
const PHONE_NUMBER_REGEXP = "/^\\+[0-9]+$/";
/**
* Base class of a user account.
@ -29,7 +29,7 @@ class Account {
private AccountUser $user;
/**
* @var array account's teams
* @var Team[] account's teams
*/
private array $teams;
@ -38,10 +38,13 @@ class Account {
*/
private int $id;
/**
* @param string $email
* @param string $phoneNumber
* @param AccountUser $user
* @param Team[] $teams
* @param int $id
*/
public function __construct(string $email, string $phoneNumber, AccountUser $user, array $teams, int $id) {
$this->email = $email;
@ -79,7 +82,7 @@ class Account {
* @param string $phoneNumber
*/
public function setPhoneNumber(string $phoneNumber): void {
if (!filter_var($phoneNumber, FILTER_VALIDATE_REGEXP, PHONE_NUMBER_REGEXP)) {
if (!preg_match(PHONE_NUMBER_REGEXP, $phoneNumber)) {
throw new InvalidArgumentException("Invalid phone number");
}
$this->phoneNumber = $phoneNumber;
@ -89,6 +92,9 @@ class Account {
return $this->id;
}
/**
* @return Team[]
*/
public function getTeams(): array {
return $this->teams;
}
@ -96,4 +102,4 @@ class Account {
public function getUser(): AccountUser {
return $this->user;
}
}
}

@ -36,17 +36,17 @@ class AccountUser implements User {
return $this->age;
}
public function setName(string $name) {
public function setName(string $name): void {
$this->name = $name;
}
public function setProfilePicture(Url $profilePicture) {
public function setProfilePicture(Url $profilePicture): void {
$this->profilePicture = $profilePicture;
}
public function setAge(int $age) {
public function setAge(int $age): void {
$this->age = $age;
}
}
}

@ -14,7 +14,7 @@ class Color {
* @param int $value 6 bytes unsigned int that represents an RGB color
* @throws \InvalidArgumentException if the value is negative or greater than 0xFFFFFF
*/
public function __constructor(int $value) {
public function __construct(int $value) {
if ($value < 0 || $value > 0xFFFFFF) {
throw new InvalidArgumentException("int color value is invalid, must be positive and lower than 0xFFFFFF");
}
@ -27,4 +27,4 @@ class Color {
public function getValue(): int {
return $this->value;
}
}
}

@ -39,4 +39,4 @@ class Member {
return $this->role;
}
}
}

@ -2,7 +2,6 @@
namespace App\Data;
use http\Exception\InvalidArgumentException;
/**
@ -37,4 +36,4 @@ final class MemberRole {
return ($this->value == self::ROLE_COACH);
}
}
}

@ -30,7 +30,10 @@ class TacticInfo implements \JsonSerializable {
return $this->creation_date;
}
public function jsonSerialize() {
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array {
return get_object_vars($this);
}
}
}

@ -11,7 +11,7 @@ class Team {
private Color $secondColor;
/**
* @var array maps users with their role
* @var Member[] maps users with their role
*/
private array $members;
@ -20,7 +20,7 @@ class Team {
* @param Url $picture
* @param Color $mainColor
* @param Color $secondColor
* @param array $members
* @param Member[] $members
*/
public function __construct(string $name, Url $picture, Color $mainColor, Color $secondColor, array $members) {
$this->name = $name;
@ -58,8 +58,11 @@ class Team {
return $this->secondColor;
}
/**
* @return Member[]
*/
public function listMembers(): array {
return array_map(fn ($id, $role) => new Member($id, $role), $this->members);
return $this->members;
}
}
}

@ -4,7 +4,6 @@ namespace App\Data;
use http\Url;
/**
* Public information about a user
*/
@ -24,4 +23,4 @@ interface User {
* @return int The user's age
*/
public function getAge(): int;
}
}

@ -0,0 +1,47 @@
<?php
namespace App\Gateway;
use App\Connexion;
use PDO;
class AuthGateway {
private Connexion $con;
/**
* @param Connexion $con
*/
public function __construct(Connexion $con) {
$this->con = $con;
}
public function mailExist(string $email): bool {
return $this->getUserFields($email) != null;
}
public function insertAccount(string $username, string $hash, string $email): void {
$this->con->exec("INSERT INTO AccountUser VALUES (:username,:hash,:email)", [':username' => [$username, PDO::PARAM_STR],':hash' => [$hash, PDO::PARAM_STR],':email' => [$email, PDO::PARAM_STR]]);
}
public function getUserHash(string $email): string {
$results = $this->con->fetch("SELECT hash FROM AccountUser WHERE email = :email", [':email' => [$email, PDO::PARAM_STR]]);
return $results[0]['hash'];
}
/**
* @param string $email
* @return array<string,string>|null
*/
public function getUserFields(string $email): ?array {
$results = $this->con->fetch("SELECT username,email FROM AccountUser WHERE email = :email", [':email' => [$email, PDO::PARAM_STR]]);
$firstRow = $results[0] ?? null;
return $firstRow;
}
}

@ -0,0 +1,35 @@
<?php
namespace App\Gateway;
use PDO;
use App\Connexion;
/**
* A sample gateway, that stores the sample form's result.
*/
class FormResultGateway {
private Connexion $con;
public function __construct(Connexion $con) {
$this->con = $con;
}
public function insert(string $username, string $description): void {
$this->con->exec(
"INSERT INTO FormEntries VALUES (:name, :description)",
[
":name" => [$username, PDO::PARAM_STR],
"description" => [$description, PDO::PARAM_STR],
]
);
}
/**
* @return array<string, mixed>
*/
public function listResults(): array {
return $this->con->fetch("SELECT * FROM FormEntries", []);
}
}

@ -4,7 +4,7 @@ namespace App\Gateway;
use App\Connexion;
use App\Data\TacticInfo;
use \PDO;
use PDO;
class TacticInfoGateway {
private Connexion $con;
@ -43,14 +43,14 @@ class TacticInfoGateway {
return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"]));
}
public function updateName(int $id, string $name) {
public function updateName(int $id, string $name): void {
$this->con->exec(
"UPDATE TacticInfo SET name = :name WHERE id = :id",
[
":name" => [$name, PDO::PARAM_STR],
":id" => [$id, PDO::PARAM_INT]
":id" => [$id, PDO::PARAM_INT],
]
);
}
}
}

@ -10,4 +10,4 @@ class HttpCodes {
public const BAD_REQUEST = 400;
public const NOT_FOUND = 404;
}
}

@ -4,12 +4,23 @@ namespace App\Http;
use App\Validation\FieldValidationFail;
use App\Validation\Validation;
use App\Validation\ValidationFail;
use App\Validation\Validator;
use ArrayAccess;
use Exception;
/**
* @implements ArrayAccess<string, mixed>
* */
class HttpRequest implements ArrayAccess {
/**
* @var array<string, mixed>
*/
private array $data;
/**
* @param array<string, mixed> $data
*/
private function __construct(array $data) {
$this->data = $data;
}
@ -17,9 +28,9 @@ class HttpRequest implements ArrayAccess {
/**
* Creates a new HttpRequest instance, and ensures that the given request data validates the given schema.
* This is a simple function that only supports flat schemas (non-composed, the data must only be a k/v array pair.)
* @param array $request the request's data
* @param array $fails a reference to a failure array, that will contain the reported validation failures.
* @param array $schema the schema to satisfy. a schema is a simple array with a string key (which is the top-level field name), and a set of validators
* @param array<string, mixed> $request the request's data
* @param array<string, ValidationFail> $fails a reference to a failure array, that will contain the reported validation failures.
* @param array<string, Validator[]> $schema the schema to satisfy. a schema is a simple array with a string key (which is the top-level field name), and a set of validators
* @return HttpRequest|null the built HttpRequest instance, or null if a field is missing, or if any of the schema validator failed
*/
public static function from(array $request, array &$fails, array $schema): ?HttpRequest {
@ -43,15 +54,28 @@ class HttpRequest implements ArrayAccess {
return isset($this->data[$offset]);
}
/**
* @param $offset
* @return mixed
*/
public function offsetGet($offset) {
return $this->data[$offset];
}
/**
* @param $offset
* @param $value
* @throws Exception
*/
public function offsetSet($offset, $value) {
throw new Exception("requests are immutable objects.");
}
/**
* @param $offset
* @throws Exception
*/
public function offsetUnset($offset) {
throw new Exception("requests are immutable objects.");
}
}
}

@ -3,7 +3,6 @@
namespace App\Http;
class HttpResponse {
private int $code;
/**
@ -21,4 +20,4 @@ class HttpResponse {
return new HttpResponse($code);
}
}
}

@ -3,7 +3,6 @@
namespace App\Http;
class JsonHttpResponse extends HttpResponse {
/**
* @var mixed Any JSON serializable value
*/
@ -26,4 +25,4 @@ class JsonHttpResponse extends HttpResponse {
return $result;
}
}
}

@ -3,7 +3,6 @@
namespace App\Http;
class ViewHttpResponse extends HttpResponse {
public const TWIG_VIEW = 0;
public const REACT_VIEW = 1;
@ -12,7 +11,7 @@ class ViewHttpResponse extends HttpResponse {
*/
private string $file;
/**
* @var array View arguments
* @var array<string, mixed> View arguments
*/
private array $arguments;
/**
@ -24,7 +23,7 @@ class ViewHttpResponse extends HttpResponse {
* @param int $code
* @param int $kind
* @param string $file
* @param array $arguments
* @param array<string, mixed> $arguments
*/
private function __construct(int $kind, string $file, array $arguments, int $code = HttpCodes::OK) {
parent::__construct($code);
@ -41,6 +40,9 @@ class ViewHttpResponse extends HttpResponse {
return $this->file;
}
/**
* @return array<string, string>
*/
public function getArguments(): array {
return $this->arguments;
}
@ -48,7 +50,7 @@ class ViewHttpResponse extends HttpResponse {
/**
* Create a twig view response
* @param string $file
* @param array $arguments
* @param array<string, mixed> $arguments
* @param int $code
* @return ViewHttpResponse
*/
@ -59,7 +61,7 @@ class ViewHttpResponse extends HttpResponse {
/**
* Create a react view response
* @param string $file
* @param array $arguments
* @param array<string, mixed> $arguments
* @param int $code
* @return ViewHttpResponse
*/
@ -67,4 +69,4 @@ class ViewHttpResponse extends HttpResponse {
return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code);
}
}
}

@ -0,0 +1,80 @@
<?php
namespace App\Model;
use App\Controller\AuthController;
use App\Gateway\AuthGateway;
use App\Validation\FieldValidationFail;
use App\Validation\ValidationFail;
class AuthModel {
private AuthGateway $gateway;
/**
* @param AuthGateway $gateway
*/
public function __construct(AuthGateway $gateway) {
$this->gateway = $gateway;
}
/**
* @param string $username
* @param string $password
* @param string $confirmPassword
* @param string $email
* @return ValidationFail[]
*/
public function register(string $username, string $password, string $confirmPassword, string $email): array {
$errors = [];
if ($password != $confirmPassword) {
$errors[] = new FieldValidationFail("confirmpassword", "password and password confirmation are not equals");
}
if ($this->gateway->mailExist($email)) {
$errors[] = new FieldValidationFail("email", "email already exist");
}
if(empty($errors)) {
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->gateway->insertAccount($username, $hash, $email);
}
return $errors;
}
/**
* @param string $email
* @return array<string,string>|null
*/
public function getUserFields(string $email): ?array {
return $this->gateway->getUserFields($email);
}
/**
* @param string $email
* @param string $password
* @return ValidationFail[] $errors
*/
public function login(string $email, string $password): array {
$errors = [];
if (!$this->gateway->mailExist($email)) {
$errors[] = new FieldValidationFail("email", "email doesnt exists");
return $errors;
}
$hash = $this->gateway->getUserHash($email);
if (!password_verify($password, $hash)) {
$errors[] = new FieldValidationFail("password", "invalid password");
}
return $errors;
}
}

@ -6,7 +6,6 @@ use App\Data\TacticInfo;
use App\Gateway\TacticInfoGateway;
class TacticModel {
public const TACTIC_DEFAULT_NAME = "Nouvelle tactique";
@ -40,7 +39,7 @@ class TacticModel {
* Update the name of a tactic
* @param int $id the tactic identifier
* @param string $name the new name to set
* @return true if the update was done successfully
* @return bool true if the update was done successfully
*/
public function updateName(int $id, string $name): bool {
if ($this->tactics->get($id) == null) {
@ -51,4 +50,4 @@ class TacticModel {
return true;
}
}
}

@ -3,7 +3,6 @@
namespace App\Validation;
class ComposedValidator extends Validator {
private Validator $first;
private Validator $then;
@ -21,4 +20,4 @@ class ComposedValidator extends Validator {
$thenFailures = $this->then->validate($name, $val);
return array_merge($firstFailures, $thenFailures);
}
}
}

@ -2,7 +2,6 @@
namespace App\Validation;
/**
* An error that concerns a field, with a bound message name
*/
@ -34,7 +33,10 @@ class FieldValidationFail extends ValidationFail {
return new FieldValidationFail($fieldName, "field is missing");
}
public function jsonSerialize() {
/**
* @return array<string, string>
*/
public function jsonSerialize(): array {
return ["field" => $this->fieldName, "message" => $this->getMessage()];
}
}
}

@ -3,11 +3,13 @@
namespace App\Validation;
class FunctionValidator extends Validator {
/**
* @var callable(string, mixed): ValidationFail[]
*/
private $validate_fn;
/**
* @param callable $validate_fn the validate function. Must have the same signature as the {@link Validator::validate()} method.
* @param callable(string, mixed): ValidationFail[] $validate_fn the validate function. Must have the same signature as the {@link Validator::validate()} method.
*/
public function __construct(callable $validate_fn) {
$this->validate_fn = $validate_fn;
@ -16,4 +18,4 @@ class FunctionValidator extends Validator {
public function validate(string $name, $val): array {
return call_user_func_array($this->validate_fn, [$name, $val]);
}
}
}

@ -6,13 +6,18 @@ namespace App\Validation;
* A simple validator that takes a predicate and an error factory
*/
class SimpleFunctionValidator extends Validator {
/**
* @var callable(mixed): bool
*/
private $predicate;
/**
* @var callable(string): ValidationFail[]
*/
private $errorFactory;
/**
* @param callable $predicate a function predicate with signature: `(string) => bool`, to validate the given string
* @param callable $errorsFactory a factory function with signature `(string) => array` to emit failures when the predicate fails
* @param callable(mixed): bool $predicate a function predicate with signature: `(string) => bool`, to validate the given string
* @param callable(string): ValidationFail[] $errorsFactory a factory function with signature `(string) => array` to emit failures when the predicate fails
*/
public function __construct(callable $predicate, callable $errorsFactory) {
$this->predicate = $predicate;
@ -25,4 +30,4 @@ class SimpleFunctionValidator extends Validator {
}
return [];
}
}
}

@ -6,12 +6,11 @@ namespace App\Validation;
* Utility class for validation
*/
class Validation {
/**
* Validate a value from validators, appending failures in the given errors array.
* @param mixed $val the value to validate
* @param string $valName the name of the value
* @param array $failures array to push when a validator fails
* @param ValidationFail[] $failures array to push when a validator fails
* @param Validator ...$validators given validators
* @return bool true if any of the given validators did fail
*/
@ -27,4 +26,4 @@ class Validation {
return $had_errors;
}
}
}

@ -2,7 +2,9 @@
namespace App\Validation;
class ValidationFail implements \JsonSerializable {
use JsonSerializable;
class ValidationFail implements JsonSerializable {
private string $kind;
private string $message;
@ -24,7 +26,10 @@ class ValidationFail implements \JsonSerializable {
return $this->kind;
}
public function jsonSerialize() {
/**
* @return array<string, string>
*/
public function jsonSerialize(): array {
return ["error" => $this->kind, "message" => $this->message];
}
@ -32,4 +37,4 @@ class ValidationFail implements \JsonSerializable {
return new ValidationFail("not found", $message);
}
}
}

@ -3,14 +3,13 @@
namespace App\Validation;
abstract class Validator {
/**
* validates a variable string
* @param string $name the name of the tested value
* @param mixed $val the value to validate
* @return array the errors the validator has reported
* @return ValidationFail[] the errors the validator has reported
*/
public abstract function validate(string $name, $val): array;
abstract public function validate(string $name, $val): array;
/**
* Creates a validator composed of this validator, and given validator
@ -21,4 +20,4 @@ abstract class Validator {
return new ComposedValidator($this, $other);
}
}
}

@ -6,11 +6,10 @@ namespace App\Validation;
* A collection of standard validators
*/
class Validators {
/**
* @return Validator a validator that validates a given regex
*/
public static function regex(string $regex, string $msg = null): Validator {
public static function regex(string $regex, ?string $msg = null): Validator {
return new SimpleFunctionValidator(
fn(string $str) => preg_match($regex, $str),
fn(string $name) => [new FieldValidationFail($name, $msg == null ? "field does not validates pattern $regex" : $msg)]
@ -20,7 +19,7 @@ class Validators {
/**
* @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-` and `_`.
*/
public static function name($msg = null): Validator {
public static function name(?string $msg = null): Validator {
return self::regex("/^[0-9a-zA-Zà-üÀ-Ü_-]*$/", $msg);
}
@ -51,4 +50,4 @@ class Validators {
}
);
}
}
}

@ -0,0 +1,46 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profil Utilisateur</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
align-items: start;
justify-content: center;
height: 100vh;
}
.user-profile {
background-color: #7FBFFF;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 100%;
text-align: center;
}
h1 {
color: #333;
}
p {
color: #666;
}
</style>
</head>
<body>
<div class="user-profile">
<h1>Votre profil</h1>
<p><strong>Pseudo : </strong> {{ username }} </p>
<p><strong>Email : {{ email }} </strong></p>
</div>
</body>
</html>

@ -0,0 +1,85 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Connexion</title>
</head>
<body>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
{% for err in bad_fields %}
.form-group #{{ err }} {
border-color: red;
}
{% endfor %}
</style>
<div class="container">
<center><h2>Se connecter</h2></center>
<form action="login" method="post">
<div class="form-group">
<label for="email">Email :</label>
<input type="text" id="email" name="email" required>
<label for= "password">Mot de passe :</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<input type="submit" value="S'identifier">
</div>
</form>
</div>
</body>
</html>

@ -0,0 +1,88 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>S'enregistrer</title>
</head>
<body>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f1f1f1;
}
.container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
input[type="submit"] {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
{% for err in bad_fields %}
.form-group #{{ err }} {
border-color: red;
}
{% endfor %}
</style>
<div class="container">
<center><h2>S'enregistrer</h2></center>
<form action="register" method="post">
<div class="form-group">
<label for="username">Nom d'utilisateur :</label>
<input type="text" id="username" name="username" required>
<label for= "password">Mot de passe :</label>
<input type="password" id="password" name="password" required>
<label for="confirmpassword">Confirmer le mot de passe :</label>
<input type="password" id="confirmpassword" name="confirmpassword" required>
<label for="email">Email :</label>
<input type="text" id="email" name="email" required>
</div>
<div class="form-group">
<input type="submit" value="Confirmer">
</div>
</form>
</div>
</body>
</html>

@ -1,4 +1,3 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@ -14,5 +13,6 @@
<p>description: {{ v.description }}</p>
{% endfor %}
</body>
</html>

@ -0,0 +1,19 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Twig view</title>
</head>
<body>
<h1>Hello, this is a sample form made in Twig !</h1>
<form action="submit-twig" method="POST">
<label for="name">your name: </label>
<input type="text" id="name" name="name"/>
<label for="password">a little description about yourself: </label>
<input type="text" id="password" name="description"/>
<input type="submit" value="click me to submit!"/>
</form>
</body>
</html>

@ -3,11 +3,11 @@
/**
* sends a react view to the user client.
* @param string $url url of the react file to render
* @param array $arguments arguments to pass to the rendered react component
* The arguments must be a json-encodable key/value dictionary.
* @param array<string, mixed> $arguments arguments to pass to the rendered react component
* The arguments must be a json-encodable key/value dictionary.
* @return void
*/
function send_react_front(string $url, array $arguments) {
// the $url and $argument values are used into the included file
require_once "react-display-file.php";
}
}

@ -6,7 +6,7 @@
"dom.iterable",
"esnext"
],
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-svgr/client"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,

@ -0,0 +1,9 @@
#!/usr/bin/env bash
## verify php and typescript types
echo "running php typechecking"
vendor/bin/phpstan analyze && echo "php types are respected"
echo "running typescript typechecking"
npm run tsc && echo "typescript types are respected"

@ -2,6 +2,7 @@ import {defineConfig} from "vite";
import react from '@vitejs/plugin-react';
import fs from "fs";
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import svgr from "vite-plugin-svgr";
function resolve_entries(dirname: string): [string, string][] {
@ -38,6 +39,9 @@ export default defineConfig({
react(),
cssInjectedByJsPlugin({
relativeCSSInjection: true,
}),
svgr({
include: "**/*.svg?react"
})
]
})

Loading…
Cancel
Save