WIP: apply suggestions
continuous-integration/drone/push Build is passing Details

maxime 1 year ago
parent df10eba8d2
commit c8cc65bf96

@ -1,2 +1,2 @@
#VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master #VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master
VITE_API_ENDPOINT=http://localhost:5254 VITE_API_ENDPOINT=http://grospc:5254

@ -1,2 +0,0 @@
VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master
#VITE_API_ENDPOINT=http://localhost:5254

@ -1,9 +0,0 @@
#!/usr/bin/env bash
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 -E -i 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php
DRONE_BRANCH_ESCAPED=$(sed -E 's/\//\\\//g' <<< "$DRONE_BRANCH")
sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\/IQBall\/$DRONE_BRANCH_ESCAPED\/public\";/" profiles/prod-config-profile.php
rm profiles/dev-config-profile.php
mv src config.php sql profiles vendor /outputs/

@ -1,6 +1,6 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react" import React, { ReactNode, useCallback, useRef, useState } from "react"
export interface SlideLayoutProps { export interface CurtainLayoutProps {
children: [ReactNode, ReactNode] children: [ReactNode, ReactNode]
rightWidth: number rightWidth: number
onRightWidthChange: (w: number) => void onRightWidthChange: (w: number) => void
@ -10,12 +10,11 @@ export default function CurtainLayout({
children, children,
rightWidth, rightWidth,
onRightWidthChange, onRightWidthChange,
}: SlideLayoutProps) { }: CurtainLayoutProps) {
const curtainRef = useRef<HTMLDivElement>(null) const curtainRef = useRef<HTMLDivElement>(null)
const sliderRef = useRef<HTMLDivElement>(null)
const resize = useCallback( const resize = useCallback(
(e: MouseEvent) => { (e: React.MouseEvent) => {
const sliderPosX = e.clientX const sliderPosX = e.clientX
const curtainWidth = const curtainWidth =
curtainRef.current!.getBoundingClientRect().width curtainRef.current!.getBoundingClientRect().width
@ -27,37 +26,18 @@ export default function CurtainLayout({
const [resizing, setResizing] = useState(false) const [resizing, setResizing] = useState(false)
useEffect(() => {
const curtain = curtainRef.current!
const slider = sliderRef.current!
if (resizing) {
const handleMouseUp = () => setResizing(false)
curtain.addEventListener("mousemove", resize)
curtain.addEventListener("mouseup", handleMouseUp)
return () => {
curtain.removeEventListener("mousemove", resize)
curtain.removeEventListener("mouseup", handleMouseUp)
}
}
const handleMouseDown = () => setResizing(true)
slider.addEventListener("mousedown", handleMouseDown)
return () => {
slider.removeEventListener("mousedown", handleMouseDown)
}
}, [sliderRef, curtainRef, resizing, setResizing, resize])
return ( return (
<div className={"curtain"} ref={curtainRef} style={{ display: "flex" }}> <div
className={"curtain"}
ref={curtainRef}
style={{ display: "flex" }}
onMouseMove={resizing ? resize : undefined}
onMouseUp={() => setResizing(false)}>
<div className={"curtain-left"} style={{ width: `${rightWidth}%` }}> <div className={"curtain-left"} style={{ width: `${rightWidth}%` }}>
{children[0]} {children[0]}
</div> </div>
<div <div
ref={sliderRef} onMouseDown={() => setResizing(true)}
style={{ style={{
width: 4, width: 4,
height: "100%", height: "100%",

@ -1,7 +1,7 @@
import "../../style/steps_tree.css" import "../../style/steps_tree.css"
import { StepInfoNode } from "../../model/tactic/Tactic" import { StepInfoNode } from "../../model/tactic/Tactic"
import BendableArrow from "../arrows/BendableArrow" import BendableArrow from "../arrows/BendableArrow"
import { useRef } from "react" import { ReactNode, useMemo, useRef } from "react"
import AddSvg from "../../assets/icon/add.svg?react" import AddSvg from "../../assets/icon/add.svg?react"
import RemoveSvg from "../../assets/icon/remove.svg?react" import RemoveSvg from "../../assets/icon/remove.svg?react"
import { getStepName } from "../../editor/StepsDomain.ts" import { getStepName } from "../../editor/StepsDomain.ts"
@ -55,21 +55,20 @@ function StepsTreeNode({
}: StepsTreeContentProps) { }: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const stepId = getStepName(rootNode, node.id)
return ( return (
<div ref={ref} className={"step-group"}> <div ref={ref} className={"step-group"}>
{node.children.map((child) => ( {node.children.map((child) => (
<BendableArrow <BendableArrow
key={child.id} key={child.id}
area={ref} area={ref}
startPos={"step-piece-" + stepId} startPos={"step-piece-" + node.id}
segments={[ segments={[
{ {
next: next: "step-piece-" + child.id,
"step-piece-" + getStepName(rootNode, child.id),
}, },
]} ]}
onSegmentsChanges={() => {}} onSegmentsChanges={() => {
}}
forceStraight={true} forceStraight={true}
wavy={false} wavy={false}
//TODO remove magic constants //TODO remove magic constants
@ -78,7 +77,7 @@ function StepsTreeNode({
/> />
))} ))}
<StepPiece <StepPiece
name={stepId} id={node.id}
isSelected={selectedStepId === node.id} isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)} onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={ onRemoveButtonClicked={
@ -87,7 +86,9 @@ function StepsTreeNode({
: () => onRemoveNode(node) : () => onRemoveNode(node)
} }
onSelected={() => onStepSelected(node)} onSelected={() => onStepSelected(node)}
/> >
<p>{useMemo(() => getStepName(rootNode, node.id), [node.id, rootNode])}</p>
</StepPiece>
<div className={"step-children"}> <div className={"step-children"}>
{node.children.map((child) => ( {node.children.map((child) => (
<StepsTreeNode <StepsTreeNode
@ -106,23 +107,25 @@ function StepsTreeNode({
} }
interface StepPieceProps { interface StepPieceProps {
name: string id: number
isSelected: boolean isSelected: boolean
onAddButtonClicked?: () => void onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void onRemoveButtonClicked?: () => void
onSelected: () => void onSelected: () => void
children?: ReactNode
} }
function StepPiece({ function StepPiece({
name, id,
isSelected, isSelected,
onAddButtonClicked, onAddButtonClicked,
onRemoveButtonClicked, onRemoveButtonClicked,
onSelected, onSelected,
children,
}: StepPieceProps) { }: StepPieceProps) {
return ( return (
<div <div
id={"step-piece-" + name} id={"step-piece-" + id}
tabIndex={1} tabIndex={1}
className={ className={
"step-piece " + (isSelected ? "step-piece-selected" : "") "step-piece " + (isSelected ? "step-piece-selected" : "")
@ -131,18 +134,18 @@ function StepPiece({
<div className="step-piece-actions"> <div className="step-piece-actions">
{onAddButtonClicked && ( {onAddButtonClicked && (
<AddSvg <AddSvg
onClick={() => onAddButtonClicked()} onClick={onAddButtonClicked}
className={"add-icon"} className={"add-icon"}
/> />
)} )}
{onRemoveButtonClicked && ( {onRemoveButtonClicked && (
<RemoveSvg <RemoveSvg
onClick={() => onRemoveButtonClicked()} onClick={onRemoveButtonClicked}
className={"remove-icon"} className={"remove-icon"}
/> />
)} )}
</div> </div>
<p>{name}</p> <p>{children}</p>
</div> </div>
) )
} }

@ -462,10 +462,10 @@ export function removeAction(
/** /**
* Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with
* the given newState. * the given newState.
* @returns the new state if it has been updated, or null if no changes were operated
* @param origin * @param origin
* @param newState * @param newState
* @param content * @param content
* @returns the new state if it has been updated, or null if no changes were operated
*/ */
export function spreadNewStateFromOriginStateChange( export function spreadNewStateFromOriginStateChange(
origin: PlayerLike, origin: PlayerLike,

@ -47,9 +47,9 @@ export function getStepNode(
export function removeStepNode( export function removeStepNode(
root: StepInfoNode, root: StepInfoNode,
node: StepInfoNode, node: number,
): StepInfoNode | undefined { ): StepInfoNode | undefined {
if (root.id === node.id) { if (root.id === node) {
return undefined return undefined
} }

@ -1,6 +1,5 @@
import { import {
CSSProperties, CSSProperties,
Dispatch,
RefObject, RefObject,
SetStateAction, SetStateAction,
useCallback, useCallback,
@ -25,9 +24,7 @@ import {
StepContent, StepContent,
StepInfoNode, StepInfoNode,
TacticComponent, TacticComponent,
TacticInfo,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import { fetchAPI, fetchAPIGet } from "../Fetcher"
import SavingState, { import SavingState, {
SaveState, SaveState,
@ -83,28 +80,23 @@ import {
removePlayer, removePlayer,
} from "../editor/PlayerDomains" } from "../editor/PlayerDomains"
import { CourtBall } from "../components/editor/CourtBall" import { CourtBall } from "../components/editor/CourtBall"
import { useNavigate, useParams } from "react-router-dom"
import StepsTree from "../components/editor/StepsTree" import StepsTree from "../components/editor/StepsTree"
import { import {
addStepNode, addStepNode,
getAvailableId,
getParent, getParent,
getStepNode, getStepNode,
removeStepNode, removeStepNode,
} from "../editor/StepsDomain" } from "../editor/StepsDomain"
import CurtainLayout from "../components/CurtainLayout" import CurtainLayout from "../components/CurtainLayout"
import { ServiceError, TacticService } from "../service/TacticService.ts"
import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.ts"
import { useParams } from "react-router-dom"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
} }
const GUEST_MODE_STEP_CONTENT_STORAGE_KEY = "guest_mode_step"
const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
// The step identifier the editor will always open on
const GUEST_MODE_ROOT_STEP_ID = 1
type ComputedRelativePositions = Map<ComponentId, Pos> type ComputedRelativePositions = Map<ComponentId, Pos>
type ComputedStepContent = { type ComputedStepContent = {
@ -112,12 +104,6 @@ type ComputedStepContent = {
relativePositions: ComputedRelativePositions relativePositions: ComputedRelativePositions
} }
interface TacticDto {
id: number
name: string
courtType: CourtType
}
export interface EditorPageProps { export interface EditorPageProps {
guestMode: boolean guestMode: boolean
} }
@ -126,193 +112,56 @@ export default function Editor({ guestMode }: EditorPageProps) {
return <EditorPortal guestMode={guestMode} /> return <EditorPortal guestMode={guestMode} />
} }
function EditorPortal({ guestMode }: EditorPageProps) { interface EditorService {
return guestMode ? <GuestModeEditor /> : <UserModeEditor /> addStep(
} parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError>
function GuestModeEditor() { removeStep(step: number): Promise<void | ServiceError>
const storageContent = localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID,
)
const stepInitialContent: StepContent = { selectStep(step: number): Promise<void | ServiceError>
...(storageContent == null
? { components: [] }
: JSON.parse(storageContent)),
}
const rootStepNode: StepInfoNode = JSON.parse( setContent(content: SetStateAction<StepContent>): void
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
// initialize local storage if we launch in guest mode setName(name: string): Promise<SaveState>
if (storageContent == null) {
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify({ id: GUEST_MODE_ROOT_STEP_ID, children: [] }),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID,
JSON.stringify(stepInitialContent),
)
} }
const tacticName = function EditorPortal({ guestMode }: EditorPageProps) {
localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? const { tacticId: idStr } = useParams()
"Nouvelle Tactique"
const courtRef = useRef<HTMLDivElement>(null)
const [stepId, setStepId] = useState(GUEST_MODE_ROOT_STEP_ID)
const [stepContent, setStepContent, saveState] = useContentState(
stepInitialContent,
SaveStates.Guest,
useMemo(
() =>
debounceAsync(async (content: StepContent) => {
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId,
JSON.stringify(content),
)
const stepsTree: StepInfoNode = JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)!,
)
await updateStepContents(
stepId,
stepsTree,
async (stepId) => {
const content = JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY +
stepId,
)!,
)
const courtBounds =
courtRef.current!.getBoundingClientRect()
const relativePositions = computeRelativePositions(
courtBounds,
content,
)
return { content, relativePositions }
},
async (stepId, content) =>
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId,
JSON.stringify(content),
),
)
return SaveStates.Guest
}, 250),
[stepId],
),
)
function getStepContent(step: number): StepContent { if (guestMode || !idStr) {
return JSON.parse( return <EditorPageWrapper service={LocalStorageTacticService.init()} />
localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!,
)
} }
return ( return <EditorPageWrapper service={new APITacticService(parseInt(idStr))} />
<EditorPage
tactic={{
id: -1,
rootStepNode,
name: tacticName,
courtType: "PLAIN",
}}
courtRef={courtRef}
currentStepContent={stepContent}
setCurrentStepContent={(content) => setStepContent(content, true)}
saveState={saveState}
currentStepId={stepId}
onNameChange={useCallback(async (name) => {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}, [])}
selectStep={useCallback(
(step) => {
setStepId(step)
setStepContent(getStepContent(step), false)
return
},
[setStepContent],
)}
onAddStep={useCallback(async (parent, content) => {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)!,
)
const nodeId = getAvailableId(root)
const node = { id: nodeId, children: [] }
const resultTree = addStepNode(root, parent, node)
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(resultTree),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id,
JSON.stringify(content),
)
return node
}, [])}
onRemoveStep={useCallback(async (step) => {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)!,
)
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(removeStepNode(root, step)),
)
return true
}, [])}
/>
)
} }
function UserModeEditor() { function EditorPageWrapper({ service }: { service: TacticService }) {
const [tactic, setTactic] = useState<TacticDto | null>(null) const [panicMessage, setPanicMessage] = useState<string>()
const [stepsTree, setStepsTree] = useState<StepInfoNode>({ const [stepId, setStepId] = useState<number>()
id: -1, const [tacticName, setTacticName] = useState<string>()
children: [], const [courtType, setCourtType] = useState<CourtType>()
}) const [stepsTree, setStepsTree] = useState<StepInfoNode>()
const { tacticId: idStr } = useParams()
const tacticId = parseInt(idStr!)
const navigation = useNavigate()
const courtRef = useRef<HTMLDivElement>(null) const courtRef = useRef<HTMLDivElement>(null)
const [stepId, setStepId] = useState(-1)
const saveContent = useCallback( const saveContent = useCallback(
async (content: StepContent) => { async (content: StepContent) => {
const response = await fetchAPI( const result = await service.saveContent(stepId!, content)
`tactics/${tacticId}/steps/${stepId}`,
{ content }, if (typeof result === "string") return SaveStates.Err
"PUT",
)
await updateStepContents( await updateStepContents(
stepId, stepId!,
stepsTree, stepsTree!,
async (id) => { async (id) => {
const response = await fetchAPIGet( const content = await service.getContent(id)
`tactics/${tacticId}/steps/${id}`, if (typeof content === "string")
)
if (!response.ok)
throw new Error( throw new Error(
"Error when retrieving children content", "Error when retrieving children content",
) )
const content = await response.json()
const courtBounds = const courtBounds =
courtRef.current!.getBoundingClientRect() courtRef.current!.getBoundingClientRect()
const relativePositions = computeRelativePositions( const relativePositions = computeRelativePositions(
@ -325,22 +174,14 @@ function UserModeEditor() {
} }
}, },
async (id, content) => { async (id, content) => {
const response = await fetchAPI( const result = await service.saveContent(id, content)
`tactics/${tacticId}/steps/${id}`, if (typeof result === "string")
{ content }, throw new Error("Error when updating children content")
"PUT",
)
if (!response.ok) {
throw new Error(
"Error when updated new children content",
)
}
}, },
) )
return SaveStates.Ok
return response.ok ? SaveStates.Ok : SaveStates.Err
}, },
[tacticId, stepId, stepsTree], [service, stepId, stepsTree],
) )
const [stepContent, setStepContent, saveState] = const [stepContent, setStepContent, saveState] =
@ -351,153 +192,122 @@ function UserModeEditor() {
) )
useEffect(() => { useEffect(() => {
async function initialize() { async function init() {
const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) const contextResult = await service.getContext()
const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`) if (typeof contextResult === "string") {
setPanicMessage(
const infoResponse = await infoResponsePromise "There has been an error retrieving the editor initial context : " +
const treeResponse = await treeResponsePromise contextResult,
)
const { name, courtType } = await infoResponse.json()
const { root } = await treeResponse.json()
if (infoResponse.status == 401 || treeResponse.status == 401) {
navigation("/login")
return return
} }
const stepId = contextResult.stepsTree.id
setStepsTree(contextResult.stepsTree)
setStepId(stepId)
setCourtType(contextResult.courtType)
setTacticName(contextResult.name)
const contentResponsePromise = fetchAPIGet( const contentResult = await service.getContent(stepId)
`tactics/${tacticId}/steps/${root.id}`,
)
const contentResponse = await contentResponsePromise
if (contentResponse.status == 401) { if (typeof contentResult === "string") {
navigation("/login") setPanicMessage(
"There has been an error retrieving the tactic's root step content : " +
contentResult,
)
return return
} }
setStepContent(contentResult, false)
const content = await contentResponse.json()
setTactic({ id: tacticId, name, courtType })
setStepsTree(root)
setStepId(root.id)
setStepContent(content, false)
} }
if (tactic === null) initialize() init()
}, [tactic, tacticId, idStr, navigation, setStepContent]) }, [service, setStepContent, stepId])
const onNameChange = useCallback( const editorService: EditorService = useMemo(
(name: string) => () => ({
fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then( async addStep(
(r) => r.ok, parent: StepInfoNode,
), content: StepContent,
[tacticId], ): Promise<StepInfoNode | ServiceError> {
) const result = await service.addStep(parent, content)
if (typeof result !== "string")
setStepsTree(addStepNode(stepsTree!, parent, result))
return result
},
async removeStep(step: number): Promise<void | ServiceError> {
const result = await service.removeStep(step)
if (typeof result !== "string")
setStepsTree(removeStepNode(stepsTree!, step))
return result
},
const selectStep = useCallback( setContent(content: StepContent) {
async (step: number) => { setStepContent(content, true)
const response = await fetchAPIGet(
`tactics/${tacticId}/steps/${step}`,
)
if (!response.ok) return
setStepId(step)
setStepContent(await response.json(), false)
}, },
[tacticId, setStepContent],
)
const onAddStep = useCallback( async setName(name: string): Promise<SaveState> {
async (parent: StepInfoNode, content: StepContent) => { const result = await service.setName(name)
const response = await fetchAPI(`tactics/${tacticId}/steps`, { if (typeof result === "string") return SaveStates.Err
parentId: parent.id, setTacticName(name)
content, return SaveStates.Ok
})
if (!response.ok) return null
const { stepId } = await response.json()
const child = { id: stepId, children: [] }
setStepsTree(addStepNode(stepsTree, parent, child))
return child
}, },
[tacticId, stepsTree],
)
const onRemoveStep = useCallback( async selectStep(step: number): Promise<void | ServiceError> {
async (step: StepInfoNode) => { const result = await service.getContent(step)
const response = await fetchAPI( if (typeof result === "string") return result
`tactics/${tacticId}/steps/${step.id}`, setStepId(step)
{}, setStepContent(result, false)
"DELETE",
)
setStepsTree(removeStepNode(stepsTree, step)!)
return response.ok
}, },
[tacticId, stepsTree], }),
[service, setStepContent, stepsTree],
) )
if (!tactic) return <EditorLoadingScreen /> if (panicMessage) {
return <p>{panicMessage}</p>
}
if (!tacticName || !stepId || !stepsTree || !courtType) {
return <p>Retrieving editor context. Please wait...</p>
}
return ( return (
<EditorPage <EditorPage
tactic={{ name={tacticName}
id: tacticId, courtType={courtType}
name: tactic?.name ?? "", stepId={stepId}
rootStepNode: stepsTree, stepsTree={stepsTree}
courtType: tactic?.courtType, contentSaveState={saveState}
}} content={stepContent}
service={editorService}
courtRef={courtRef} courtRef={courtRef}
currentStepId={stepId}
currentStepContent={stepContent}
setCurrentStepContent={(content) => setStepContent(content, true)}
saveState={saveState}
onNameChange={onNameChange}
selectStep={selectStep}
onAddStep={onAddStep}
onRemoveStep={onRemoveStep}
/> />
) )
} }
function EditorLoadingScreen() {
return <p>Loading Editor, Please wait...</p>
}
export interface EditorViewProps { export interface EditorViewProps {
tactic: TacticInfo stepsTree: StepInfoNode
currentStepContent: StepContent name: string
currentStepId: number courtType: CourtType
saveState: SaveState content: StepContent
setCurrentStepContent: Dispatch<SetStateAction<StepContent>> contentSaveState: SaveState
stepId: number
courtRef: RefObject<HTMLDivElement> courtRef: RefObject<HTMLDivElement>
selectStep: (stepId: number) => void service: EditorService
onNameChange: (name: string) => Promise<boolean>
onRemoveStep: (step: StepInfoNode) => Promise<boolean>
onAddStep: (
parent: StepInfoNode,
content: StepContent,
) => Promise<StepInfoNode | null>
} }
function EditorPage({ function EditorPage({
tactic: { name, rootStepNode: initialStepsNode, courtType }, name,
currentStepId, courtType,
setCurrentStepContent: setContent, content,
currentStepContent: content, stepId,
saveState, contentSaveState,
onNameChange, stepsTree,
selectStep,
onRemoveStep,
onAddStep,
courtRef, courtRef,
service,
}: EditorViewProps) { }: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({}) const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
const allies = getRackPlayers(PlayerTeam.Allies, content.components) const allies = getRackPlayers(PlayerTeam.Allies, content.components)
const opponents = getRackPlayers(PlayerTeam.Opponents, content.components) const opponents = getRackPlayers(PlayerTeam.Opponents, content.components)
@ -527,7 +337,7 @@ function EditorPage({
}, [content, courtRef]) }, [content, courtRef])
const setComponents = (action: SetStateAction<TacticComponent[]>) => { const setComponents = (action: SetStateAction<TacticComponent[]>) => {
setContent((c) => ({ service.setContent((c) => ({
...c, ...c,
components: components:
typeof action == "function" ? action(c.components) : action, typeof action == "function" ? action(c.components) : action,
@ -546,15 +356,15 @@ function EditorPage({
const doRemovePlayer = useCallback( const doRemovePlayer = useCallback(
(component: PlayerLike) => { (component: PlayerLike) => {
setContent((c) => removePlayer(component, c)) service.setContent((c) => removePlayer(component, c))
if (component.type == "player") insertRackedPlayer(component) if (component.type == "player") insertRackedPlayer(component)
}, },
[setContent], [service],
) )
const doMoveBall = useCallback( const doMoveBall = useCallback(
(newBounds: DOMRect, from?: PlayerLike) => { (newBounds: DOMRect, from?: PlayerLike) => {
setContent((content) => { service.setContent((content) => {
if (from) { if (from) {
content = content =
spreadNewStateFromOriginStateChange( spreadNewStateFromOriginStateChange(
@ -569,12 +379,12 @@ function EditorPage({
return content return content
}) })
}, },
[courtBounds, setContent], [courtBounds, service],
) )
const validatePlayerPosition = useCallback( const validatePlayerPosition = useCallback(
(player: PlayerLike, info: PlayerInfo, newPos: Pos) => { (player: PlayerLike, info: PlayerInfo, newPos: Pos) => {
setContent((content) => service.setContent((content) =>
moveComponent( moveComponent(
newPos, newPos,
player, player,
@ -589,7 +399,7 @@ function EditorPage({
), ),
) )
}, },
[courtBounds, setContent], [courtBounds, service],
) )
const renderAvailablePlayerActions = useCallback( const renderAvailablePlayerActions = useCallback(
@ -631,7 +441,7 @@ function EditorPage({
playerInfo={info} playerInfo={info}
content={content} content={content}
courtRef={courtRef} courtRef={courtRef}
setContent={setContent} setContent={service.setContent}
/> />
), ),
!isFrozen && !isFrozen &&
@ -646,7 +456,13 @@ function EditorPage({
), ),
] ]
}, },
[content, courtRef, doMoveBall, previewAction?.isInvalid, setContent], [
content,
courtRef,
doMoveBall,
previewAction?.isInvalid,
service.setContent,
],
) )
const renderPlayer = useCallback( const renderPlayer = useCallback(
@ -713,14 +529,14 @@ function EditorPage({
const doDeleteAction = useCallback( const doDeleteAction = useCallback(
(_: Action, idx: number, origin: TacticComponent) => { (_: Action, idx: number, origin: TacticComponent) => {
setContent((content) => removeAction(origin, idx, content)) service.setContent((content) => removeAction(origin, idx, content))
}, },
[setContent], [service],
) )
const doUpdateAction = useCallback( const doUpdateAction = useCallback(
(component: TacticComponent, action: Action, actionIndex: number) => { (component: TacticComponent, action: Action, actionIndex: number) => {
setContent((content) => service.setContent((content) =>
updateComponent( updateComponent(
{ {
...component, ...component,
@ -734,7 +550,7 @@ function EditorPage({
), ),
) )
}, },
[setContent], [service],
) )
const renderComponent = useCallback( const renderComponent = useCallback(
@ -749,7 +565,7 @@ function EditorPage({
ball={component} ball={component}
onPosValidated={doMoveBall} onPosValidated={doMoveBall}
onRemove={() => { onRemove={() => {
setContent((content) => removeBall(content)) service.setContent((content) => removeBall(content))
setObjects((objects) => [ setObjects((objects) => [
...objects, ...objects,
{ key: "ball" }, { key: "ball" },
@ -760,7 +576,7 @@ function EditorPage({
} }
throw new Error("unknown tactic component " + component) throw new Error("unknown tactic component " + component)
}, },
[renderPlayer, doMoveBall, setContent], [service, renderPlayer, doMoveBall],
) )
const renderActions = useCallback( const renderActions = useCallback(
@ -782,7 +598,7 @@ function EditorPage({
/> />
) )
}), }),
[courtRef, doDeleteAction, doUpdateAction, editorContentCurtainWidth], [courtRef, doDeleteAction, doUpdateAction],
) )
const contentNode = ( const contentNode = (
@ -809,7 +625,7 @@ function EditorPage({
)} )}
onElementDetached={useCallback( onElementDetached={useCallback(
(r, e: RackedCourtObject) => (r, e: RackedCourtObject) =>
setContent((content) => service.setContent((content) =>
placeObjectAt( placeObjectAt(
r.getBoundingClientRect(), r.getBoundingClientRect(),
courtBounds(), courtBounds(),
@ -817,7 +633,7 @@ function EditorPage({
content, content,
), ),
), ),
[courtBounds, setContent], [courtBounds, service],
)} )}
render={renderCourtObject} render={renderCourtObject}
/> />
@ -846,41 +662,32 @@ function EditorPage({
const stepsTreeNode = ( const stepsTreeNode = (
<EditorStepsTree <EditorStepsTree
selectedStepId={currentStepId} selectedStepId={stepId}
root={rootStepsNode} root={stepsTree}
onAddChildren={useCallback( onAddChildren={useCallback(
async (parent) => { async (parent) => {
const addedNode = await onAddStep( const addedNode = await service.addStep(
parent, parent,
computeTerminalState(content, relativePositions), computeTerminalState(content, relativePositions),
) )
if (addedNode == null) { if (typeof addedNode === "string") {
console.error( console.error("could not add step : " + addedNode)
"could not add step : onAddStep returned null node",
)
return return
} }
selectStep(addedNode.id) await service.selectStep(addedNode.id)
setRootStepsNode((root) =>
addStepNode(root, parent, addedNode),
)
}, },
[content, onAddStep, selectStep, relativePositions], [service, content, relativePositions],
)} )}
onRemoveNode={useCallback( onRemoveNode={useCallback(
async (removed) => { async (removed) => {
const isOk = await onRemoveStep(removed) await service.removeStep(removed.id)
selectStep(getParent(rootStepsNode, removed)!.id) await service.selectStep(getParent(stepsTree, removed)!.id)
if (isOk)
setRootStepsNode(
(root) => removeStepNode(root, removed)!,
)
}, },
[rootStepsNode, onRemoveStep, selectStep], [service, stepsTree],
)} )}
onStepSelected={useCallback( onStepSelected={useCallback(
(node) => selectStep(node.id), (node) => service.selectStep(node.id),
[selectStep], [service],
)} )}
/> />
) )
@ -889,7 +696,7 @@ function EditorPage({
<div id="main-div"> <div id="main-div">
<div id="topbar-div"> <div id="topbar-div">
<div id="topbar-left"> <div id="topbar-left">
<SavingState state={saveState} /> <SavingState state={contentSaveState} />
</div> </div>
<div id="title-input-div"> <div id="title-input-div">
<TitleInput <TitleInput
@ -897,11 +704,15 @@ function EditorPage({
default_value={name} default_value={name}
onValidated={useCallback( onValidated={useCallback(
(new_name) => { (new_name) => {
onNameChange(new_name).then((success) => { service.setName(new_name).then((state) => {
setTitleStyle(success ? {} : ERROR_STYLE) setTitleStyle(
state == SaveStates.Ok
? {}
: ERROR_STYLE,
)
}) })
}, },
[onNameChange], [service],
)} )}
/> />
</div> </div>

@ -0,0 +1,84 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { fetchAPI, fetchAPIGet } from "../Fetcher.ts"
export class APITacticService implements TacticService {
private readonly tacticId: number
constructor(tacticId: number) {
this.tacticId = tacticId
}
async getContext(): Promise<TacticContext | ServiceError> {
const infoResponsePromise = fetchAPIGet(`tactics/${this.tacticId}`)
const treeResponsePromise = fetchAPIGet(`tactics/${this.tacticId}/tree`)
const infoResponse = await infoResponsePromise
const treeResponse = await treeResponsePromise
if (infoResponse.status == 401 || treeResponse.status == 401) {
return ServiceError.UNAUTHORIZED
}
const { name, courtType } = await infoResponse.json()
const { root } = await treeResponse.json()
return { courtType, name, stepsTree: root }
}
async addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError> {
const response = await fetchAPI(`tactics/${this.tacticId}/steps`, {
parentId: parent.id,
content,
})
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
const { stepId } = await response.json()
return { id: stepId, children: [] }
}
async removeStep(id: number): Promise<void | ServiceError> {
const response = await fetchAPI(
`tactics/${this.tacticId}/steps/${id}`,
{},
"DELETE",
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
}
async setName(name: string): Promise<void | ServiceError> {
const response = await fetchAPI(
`tactics/${this.tacticId}/name`,
{ name },
"PUT",
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
}
async saveContent(
step: number,
content: StepContent,
): Promise<void | ServiceError> {
const response = await fetchAPI(
`tactics/${this.tacticId}/steps/${step}`,
{ content },
"PUT",
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
}
async getContent(step: number): Promise<StepContent | ServiceError> {
const response = await fetchAPIGet(
`tactics/${this.tacticId}/steps/${step}`,
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
return await response.json()
}
}

@ -0,0 +1,99 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import {
addStepNode,
getAvailableId,
removeStepNode,
} from "../editor/StepsDomain.ts"
const GUEST_MODE_STEP_CONTENT_STORAGE_KEY = "guest_mode_step"
const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export class LocalStorageTacticService implements TacticService {
private constructor() {}
static init(): LocalStorageTacticService {
const root = localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)
if (root === null) {
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(<StepInfoNode>{ id: 1, children: [] }),
)
}
return new LocalStorageTacticService()
}
async getContext(): Promise<TacticContext | ServiceError> {
const stepsTree: StepInfoNode = JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
const name =
localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ??
"Nouvelle Tactique"
return {
stepsTree,
name,
courtType: "PLAIN",
}
}
async addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError> {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
const nodeId = getAvailableId(root)
const node = { id: nodeId, children: [] }
const resultTree = addStepNode(root, parent, node)
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(resultTree),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id,
JSON.stringify(content),
)
return node
}
async getContent(step: number): Promise<StepContent | ServiceError> {
const content = localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
)
return content ? JSON.parse(content) : null
}
async removeStep(id: number): Promise<void | ServiceError> {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
)
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(removeStepNode(root, id)),
)
}
async saveContent(
step: number,
content: StepContent,
): Promise<void | ServiceError> {
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
JSON.stringify(content),
)
}
async setName(name: string): Promise<void | ServiceError> {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
}
}

@ -0,0 +1,32 @@
import { CourtType, StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
export interface TacticContext {
stepsTree: StepInfoNode
name: string
courtType: CourtType
}
export enum ServiceError {
UNAUTHORIZED = "UNAUTHORIZED",
NOT_FOUND = "NOT_FOUND",
}
export interface TacticService {
getContext(): Promise<TacticContext | ServiceError>
addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError>
removeStep(id: number): Promise<void | ServiceError>
setName(name: string): Promise<void | ServiceError>
saveContent(
step: number,
content: StepContent,
): Promise<void | ServiceError>
getContent(step: number): Promise<StepContent | ServiceError>
}

@ -133,7 +133,6 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
user-select: none; user-select: none;
-webkit-user-drag: none;
} }
#court-image * { #court-image * {
@ -154,8 +153,6 @@
.save-state, .save-state,
#show-steps-button { #show-steps-button {
user-select: none; user-select: none;
-moz-user-select: none;
-ms-user-select: none;
} }
.save-state-error { .save-state-error {

@ -1,51 +0,0 @@
import { beforeAll, expect, test } from "vitest"
import { fetchAPI } from "../../src/Fetcher"
import { saveSession } from "../../src/api/session"
async function login() {
const response = await fetchAPI("auth/token/", {
email: "maxime@mail.com",
password: "123456",
})
expect(response.status).toBe(200)
const { token, expirationDate } = await response.json()
saveSession({ auth: { token, expirationDate: Date.parse(expirationDate) } })
}
beforeAll(login)
test("create tactic", async () => {
await login()
const response = await fetchAPI("tactics", {
courtType: "PLAIN",
name: "test tactic",
})
expect(response.status).toBe(200)
})
test("spam step creation test", async () => {
const createTacticResponse = await fetchAPI("tactics", {
courtType: "PLAIN",
name: "test tactic",
})
expect(createTacticResponse.status).toBe(200)
const { id } = await createTacticResponse.json()
const tasks = Array.from({ length: 200 }).map(async () => {
const response = await fetchAPI(`tactics/${id}/steps`, {
parentId: 1,
content: { components: [] },
})
expect(response.status).toBe(200)
const { stepId } = await response.json()
return stepId
})
const steps = []
for (const task of tasks) {
steps.push(await task)
}
steps.sort((a, b) => a - b)
const expected = Array.from({ length: 200 }, (_, i) => i + 2)
expect(steps).toEqual(expected)
})
Loading…
Cancel
Save