diff --git a/.env.TEST b/.env.TEST deleted file mode 100644 index 5fc3483..0000000 --- a/.env.TEST +++ /dev/null @@ -1,2 +0,0 @@ -VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master -#VITE_API_ENDPOINT=http://localhost:5254 diff --git a/ci/prepare_php.sh b/ci/prepare_php.sh deleted file mode 100644 index f2525df..0000000 --- a/ci/prepare_php.sh +++ /dev/null @@ -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/ \ No newline at end of file diff --git a/src/components/CurtainLayout.tsx b/src/components/SplitLayout.tsx similarity index 50% rename from src/components/CurtainLayout.tsx rename to src/components/SplitLayout.tsx index 0be39fb..89c0364 100644 --- a/src/components/CurtainLayout.tsx +++ b/src/components/SplitLayout.tsx @@ -1,21 +1,20 @@ -import { ReactNode, useCallback, useEffect, useRef, useState } from "react" +import React, { ReactNode, useCallback, useRef, useState } from "react" -export interface SlideLayoutProps { +export interface SplitLayoutProps { children: [ReactNode, ReactNode] rightWidth: number onRightWidthChange: (w: number) => void } -export default function CurtainLayout({ +export default function SplitLayout({ children, rightWidth, onRightWidthChange, -}: SlideLayoutProps) { +}: SplitLayoutProps) { const curtainRef = useRef(null) - const sliderRef = useRef(null) const resize = useCallback( - (e: MouseEvent) => { + (e: React.MouseEvent) => { const sliderPosX = e.clientX const curtainWidth = curtainRef.current!.getBoundingClientRect().width @@ -27,37 +26,18 @@ export default function CurtainLayout({ 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 ( -
+
setResizing(false)}>
{children[0]}
setResizing(true)} style={{ width: 4, height: "100%", diff --git a/src/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx index a08204e..4b3615f 100644 --- a/src/components/arrows/BendableArrow.tsx +++ b/src/components/arrows/BendableArrow.tsx @@ -27,6 +27,7 @@ import { import "../../style/bendable_arrows.css" import Draggable from "react-draggable" +import { Segment } from "../../model/tactic/Action.ts" export interface BendableArrowProps { area: RefObject @@ -57,11 +58,6 @@ const ArrowStyleDefaults: ArrowStyle = { color: "black", } -export interface Segment { - next: Pos | string - controlPoint?: Pos -} - /** * Given a circle shaped by a central position, and a radius, return * a position that is constrained on its perimeter, pointing to the direction diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index f9c9a07..952b268 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -1,7 +1,7 @@ import "../../style/steps_tree.css" import { StepInfoNode } from "../../model/tactic/Tactic" import BendableArrow from "../arrows/BendableArrow" -import { useRef } from "react" +import { ReactNode, useMemo, useRef } from "react" import AddSvg from "../../assets/icon/add.svg?react" import RemoveSvg from "../../assets/icon/remove.svg?react" import { getStepName } from "../../editor/StepsDomain.ts" @@ -55,18 +55,16 @@ function StepsTreeNode({ }: StepsTreeContentProps) { const ref = useRef(null) - const stepId = getStepName(rootNode, node.id) return (
{node.children.map((child) => ( {}} @@ -78,7 +76,7 @@ function StepsTreeNode({ /> ))} onAddChildren(node)} onRemoveButtonClicked={ @@ -86,8 +84,14 @@ function StepsTreeNode({ ? undefined : () => onRemoveNode(node) } - onSelected={() => onStepSelected(node)} - /> + onSelected={() => onStepSelected(node)}> +

+ {useMemo( + () => getStepName(rootNode, node.id), + [node.id, rootNode], + )} +

+
{node.children.map((child) => ( void onRemoveButtonClicked?: () => void onSelected: () => void + children?: ReactNode } function StepPiece({ - name, + id, isSelected, onAddButtonClicked, onRemoveButtonClicked, onSelected, + children, }: StepPieceProps) { return (
{onAddButtonClicked && ( onAddButtonClicked()} + onClick={onAddButtonClicked} className={"add-icon"} /> )} {onRemoveButtonClicked && ( onRemoveButtonClicked()} + onClick={onRemoveButtonClicked} className={"remove-icon"} /> )}
-

{name}

+ {children}
) } diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts index 58e1613..8ad9e5a 100644 --- a/src/editor/ActionsDomains.ts +++ b/src/editor/ActionsDomains.ts @@ -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 * the given newState. - * @returns the new state if it has been updated, or null if no changes were operated * @param origin * @param newState * @param content + * @returns the new state if it has been updated, or null if no changes were operated */ export function spreadNewStateFromOriginStateChange( origin: PlayerLike, diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index 6eef2a3..1b3c918 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -59,10 +59,10 @@ export function getPrecomputedPosition( phantom: PlayerPhantom, computedPositions: Map, ): Pos | undefined { - const positioning = phantom.pos + const pos = phantom.pos // If the position is already known and fixed, return the pos - if (positioning.type === "fixed") return positioning + if (pos.type === "fixed") return pos return computedPositions.get(phantom.id) } @@ -139,10 +139,9 @@ export function computePhantomPositioning( const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area) const segmentLength = norm(segment) - const phantomDistanceFromReferent = PLAYER_RADIUS_PIXELS //TODO Place this in constants const segmentProjection = minus(area, { - x: (segment.x / segmentLength) * phantomDistanceFromReferent, - y: (segment.y / segmentLength) * phantomDistanceFromReferent, + x: (segment.x / segmentLength) * PLAYER_RADIUS_PIXELS, + y: (segment.y / segmentLength) * PLAYER_RADIUS_PIXELS, }) const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area) diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 15de927..e788a9c 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -47,19 +47,43 @@ export function getStepNode( export function removeStepNode( root: StepInfoNode, - node: StepInfoNode, + targetId: number, ): StepInfoNode | undefined { - if (root.id === node.id) { - return undefined + const path = getPathTo(root, targetId) + + path.reverse() + + const [removedNode, ...pathToRoot] = path + + let child = removedNode + + for (const node of pathToRoot) { + child = { + id: node.id, + children: node.children.flatMap((c) => { + if (c.id === removedNode.id) return [] + else if (c.id === child.id) { + return [child] + } + return [c] + }), + } } - return { - ...root, - children: root.children.flatMap((child) => { - const result = removeStepNode(child, node) - return result ? [result] : [] - }), + return child +} + +export function getPathTo( + root: StepInfoNode, + targetId: number, +): StepInfoNode[] { + if (root.id === targetId) return [root] + + for (const child of root.children) { + const subPath = getPathTo(child, targetId) + if (subPath.length > 0) return [root, ...subPath] } + return [] } /** diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index de631ca..319e4a0 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -312,10 +312,10 @@ export function computeTerminalState( computedPositions: Map, ): StepContent { const nonPhantomComponents: (Player | CourtObject)[] = - content.components.filter((c) => c.type !== "phantom") as ( - | Player - | CourtObject - )[] + content.components.filter( + (c): c is Exclude => + c.type !== "phantom", + ) const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" diff --git a/src/model/tactic/Action.ts b/src/model/tactic/Action.ts index b2dca4f..d4e459f 100644 --- a/src/model/tactic/Action.ts +++ b/src/model/tactic/Action.ts @@ -1,5 +1,4 @@ import { Pos } from "../../geo/Pos" -import { Segment } from "../../components/arrows/BendableArrow" import { ComponentId } from "./Tactic" export enum ActionKind { @@ -11,6 +10,11 @@ export enum ActionKind { export type Action = MovementAction +export interface Segment { + next: Pos | string + controlPoint?: Pos +} + export interface MovementAction { type: ActionKind target: ComponentId | Pos diff --git a/src/model/tactic/CourtObjects.ts b/src/model/tactic/CourtObjects.ts index 6751e65..55eaf6f 100644 --- a/src/model/tactic/CourtObjects.ts +++ b/src/model/tactic/CourtObjects.ts @@ -1,12 +1,10 @@ -import { Component } from "./Tactic" +import { Component, Frozable } from "./Tactic" import { Pos } from "../../geo/Pos.ts" export const BALL_ID = "ball" export const BALL_TYPE = "ball" +export type Ball = Component & Frozable + //place here all different kinds of objects export type CourtObject = Ball - -export interface Ball extends Component { - readonly frozen: boolean -} diff --git a/src/model/tactic/Player.ts b/src/model/tactic/Player.ts index 5f1d3df..bd781ac 100644 --- a/src/model/tactic/Player.ts +++ b/src/model/tactic/Player.ts @@ -1,4 +1,4 @@ -import { Component, ComponentId } from "./Tactic" +import { Component, ComponentId, Frozable } from "./Tactic" import { Pos } from "../../geo/Pos.ts" export type PlayerId = string @@ -14,7 +14,7 @@ export enum PlayerTeam { * All information about a player */ export interface PlayerInfo { - readonly id: string + readonly id: ComponentId /** * the player's team * */ @@ -25,31 +25,21 @@ export interface PlayerInfo { * */ readonly role: string - /** - * True if the player has a basketball - */ readonly ballState: BallState readonly pos: Pos } export enum BallState { - NONE, - HOLDS_ORIGIN, - HOLDS_BY_PASS, - PASSED, - PASSED_ORIGIN, + NONE = "NONE", + HOLDS_ORIGIN = "HOLDS_ORIGIN", + HOLDS_BY_PASS = "HOLDS_BY_PASS", + PASSED = "PASSED", + PASSED_ORIGIN = "PASSED_ORIGIN", } -export interface Player extends Component<"player", Pos>, PlayerInfo { - /** - * True if the player has a basketball - */ - readonly ballState: BallState - +export interface Player extends Component<"player", Pos>, PlayerInfo, Frozable { readonly path: MovementPath | null - - readonly frozen: boolean } export interface MovementPath { diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts index aa33ea7..ccf6d43 100644 --- a/src/model/tactic/Tactic.ts +++ b/src/model/tactic/Tactic.ts @@ -41,3 +41,7 @@ export interface Component { readonly actions: Action[] } + +export interface Frozable { + readonly frozen: boolean +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 158441a..a0b6708 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -1,6 +1,5 @@ import { CSSProperties, - Dispatch, RefObject, SetStateAction, useCallback, @@ -25,9 +24,7 @@ import { StepContent, StepInfoNode, TacticComponent, - TacticInfo, } from "../model/tactic/Tactic" -import { fetchAPI, fetchAPIGet } from "../Fetcher" import SavingState, { SaveState, @@ -83,28 +80,23 @@ import { removePlayer, } from "../editor/PlayerDomains" import { CourtBall } from "../components/editor/CourtBall" -import { useNavigate, useParams } from "react-router-dom" import StepsTree from "../components/editor/StepsTree" import { addStepNode, - getAvailableId, getParent, getStepNode, removeStepNode, } from "../editor/StepsDomain" -import CurtainLayout from "../components/CurtainLayout" +import SplitLayout from "../components/SplitLayout.tsx" +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 = { 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 type ComputedStepContent = { @@ -112,12 +104,6 @@ type ComputedStepContent = { relativePositions: ComputedRelativePositions } -interface TacticDto { - id: number - name: string - courtType: CourtType -} - export interface EditorPageProps { guestMode: boolean } @@ -126,193 +112,56 @@ export default function Editor({ guestMode }: EditorPageProps) { return } -function EditorPortal({ guestMode }: EditorPageProps) { - return guestMode ? : -} - -function GuestModeEditor() { - const storageContent = localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID, - ) - - const stepInitialContent: StepContent = { - ...(storageContent == null - ? { components: [] } - : JSON.parse(storageContent)), - } - - const rootStepNode: StepInfoNode = JSON.parse( - localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!, - ) +interface EditorService { + addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise - // initialize local storage if we launch in guest mode - 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), - ) - } + removeStep(step: number): Promise - const tacticName = - localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? - "Nouvelle Tactique" + selectStep(step: number): Promise - const courtRef = useRef(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, - )!, - ) + setContent(content: SetStateAction): void - 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), - ), - ) + setName(name: string): Promise +} - return SaveStates.Guest - }, 250), - [stepId], - ), - ) +function EditorPortal({ guestMode }: EditorPageProps) { + const { tacticId: idStr } = useParams() - function getStepContent(step: number): StepContent { - return JSON.parse( - localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!, - ) + if (guestMode || !idStr) { + return } - return ( - 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 - }, [])} - /> - ) + return } -function UserModeEditor() { - const [tactic, setTactic] = useState(null) - const [stepsTree, setStepsTree] = useState({ - id: -1, - children: [], - }) - const { tacticId: idStr } = useParams() - const tacticId = parseInt(idStr!) - const navigation = useNavigate() +function EditorPageWrapper({ service }: { service: TacticService }) { + const [panicMessage, setPanicMessage] = useState() + const [stepId, setStepId] = useState() + const [tacticName, setTacticName] = useState() + const [courtType, setCourtType] = useState() + const [stepsTree, setStepsTree] = useState() const courtRef = useRef(null) - const [stepId, setStepId] = useState(-1) const saveContent = useCallback( async (content: StepContent) => { - const response = await fetchAPI( - `tactics/${tacticId}/steps/${stepId}`, - { content }, - "PUT", - ) + const result = await service.saveContent(stepId!, content) + + if (typeof result === "string") return SaveStates.Err await updateStepContents( - stepId, - stepsTree, + stepId!, + stepsTree!, async (id) => { - const response = await fetchAPIGet( - `tactics/${tacticId}/steps/${id}`, - ) - if (!response.ok) + const content = await service.getContent(id) + if (typeof content === "string") throw new Error( "Error when retrieving children content", ) - const content = await response.json() const courtBounds = courtRef.current!.getBoundingClientRect() const relativePositions = computeRelativePositions( @@ -325,22 +174,14 @@ function UserModeEditor() { } }, async (id, content) => { - const response = await fetchAPI( - `tactics/${tacticId}/steps/${id}`, - { content }, - "PUT", - ) - if (!response.ok) { - throw new Error( - "Error when updated new children content", - ) - } + const result = await service.saveContent(id, content) + if (typeof result === "string") + throw new Error("Error when updating children content") }, ) - - return response.ok ? SaveStates.Ok : SaveStates.Err + return SaveStates.Ok }, - [tacticId, stepId, stepsTree], + [service, stepId, stepsTree], ) const [stepContent, setStepContent, saveState] = @@ -350,154 +191,125 @@ function UserModeEditor() { useMemo(() => debounceAsync(saveContent, 250), [saveContent]), ) - useEffect(() => { - async function initialize() { - const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) - const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`) - - const infoResponse = await infoResponsePromise - const treeResponse = await treeResponsePromise + const isNotInit = !tacticName || !stepId || !stepsTree || !courtType - const { name, courtType } = await infoResponse.json() - const { root } = await treeResponse.json() - - if (infoResponse.status == 401 || treeResponse.status == 401) { - navigation("/login") + useEffect(() => { + async function init() { + const contextResult = await service.getContext() + if (typeof contextResult === "string") { + setPanicMessage( + "There has been an error retrieving the editor initial context : " + + contextResult, + ) return } - - const contentResponsePromise = fetchAPIGet( - `tactics/${tacticId}/steps/${root.id}`, - ) - - const contentResponse = await contentResponsePromise - - if (contentResponse.status == 401) { - navigation("/login") + const stepId = contextResult.stepsTree.id + setStepsTree(contextResult.stepsTree) + setStepId(stepId) + setCourtType(contextResult.courtType) + setTacticName(contextResult.name) + + const contentResult = await service.getContent(stepId) + + if (typeof contentResult === "string") { + setPanicMessage( + "There has been an error retrieving the tactic's root step content : " + + contentResult, + ) return } - - const content = await contentResponse.json() - - setTactic({ id: tacticId, name, courtType }) - setStepsTree(root) - setStepId(root.id) - setStepContent(content, false) + setStepContent(contentResult, false) } - if (tactic === null) initialize() - }, [tactic, tacticId, idStr, navigation, setStepContent]) - - const onNameChange = useCallback( - (name: string) => - fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then( - (r) => r.ok, - ), - [tacticId], - ) - - const selectStep = useCallback( - async (step: number) => { - 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 (parent: StepInfoNode, content: StepContent) => { - const response = await fetchAPI(`tactics/${tacticId}/steps`, { - parentId: parent.id, - content, - }) - if (!response.ok) return null - const { stepId } = await response.json() - const child = { id: stepId, children: [] } - setStepsTree(addStepNode(stepsTree, parent, child)) - return child - }, - [tacticId, stepsTree], + if (isNotInit) init() + }, [isNotInit, service, setStepContent]) + + const editorService: EditorService = useMemo( + () => ({ + async addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise { + const result = await service.addStep(parent, content) + if (typeof result !== "string") + setStepsTree(addStepNode(stepsTree!, parent, result)) + return result + }, + async removeStep(step: number): Promise { + const result = await service.removeStep(step) + if (typeof result !== "string") + setStepsTree(removeStepNode(stepsTree!, step)) + return result + }, + + setContent(content: StepContent) { + setStepContent(content, true) + }, + + async setName(name: string): Promise { + const result = await service.setName(name) + if (typeof result === "string") return SaveStates.Err + setTacticName(name) + return SaveStates.Ok + }, + + async selectStep(step: number): Promise { + const result = await service.getContent(step) + if (typeof result === "string") return result + setStepId(step) + setStepContent(result, false) + }, + }), + [service, setStepContent, stepsTree], ) - const onRemoveStep = useCallback( - async (step: StepInfoNode) => { - const response = await fetchAPI( - `tactics/${tacticId}/steps/${step.id}`, - {}, - "DELETE", - ) - setStepsTree(removeStepNode(stepsTree, step)!) - return response.ok - }, - [tacticId, stepsTree], - ) + if (panicMessage) { + return

{panicMessage}

+ } - if (!tactic) return + if (isNotInit) { + return

Retrieving editor context. Please wait...

+ } return ( setStepContent(content, true)} - saveState={saveState} - onNameChange={onNameChange} - selectStep={selectStep} - onAddStep={onAddStep} - onRemoveStep={onRemoveStep} /> ) } -function EditorLoadingScreen() { - return

Loading Editor, Please wait...

-} - export interface EditorViewProps { - tactic: TacticInfo - currentStepContent: StepContent - currentStepId: number - saveState: SaveState - setCurrentStepContent: Dispatch> + stepsTree: StepInfoNode + name: string + courtType: CourtType + content: StepContent + contentSaveState: SaveState + stepId: number courtRef: RefObject - selectStep: (stepId: number) => void - onNameChange: (name: string) => Promise - onRemoveStep: (step: StepInfoNode) => Promise - onAddStep: ( - parent: StepInfoNode, - content: StepContent, - ) => Promise + service: EditorService } function EditorPage({ - tactic: { name, rootStepNode: initialStepsNode, courtType }, - currentStepId, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, - + name, + courtType, + content, + stepId, + contentSaveState, + stepsTree, courtRef, + service, }: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) - const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) - const allies = getRackPlayers(PlayerTeam.Allies, content.components) const opponents = getRackPlayers(PlayerTeam.Opponents, content.components) @@ -527,7 +339,7 @@ function EditorPage({ }, [content, courtRef]) const setComponents = (action: SetStateAction) => { - setContent((c) => ({ + service.setContent((c) => ({ ...c, components: typeof action == "function" ? action(c.components) : action, @@ -546,15 +358,15 @@ function EditorPage({ const doRemovePlayer = useCallback( (component: PlayerLike) => { - setContent((c) => removePlayer(component, c)) + service.setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, - [setContent], + [service], ) const doMoveBall = useCallback( (newBounds: DOMRect, from?: PlayerLike) => { - setContent((content) => { + service.setContent((content) => { if (from) { content = spreadNewStateFromOriginStateChange( @@ -569,12 +381,12 @@ function EditorPage({ return content }) }, - [courtBounds, setContent], + [courtBounds, service], ) const validatePlayerPosition = useCallback( (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { - setContent((content) => + service.setContent((content) => moveComponent( newPos, player, @@ -589,7 +401,7 @@ function EditorPage({ ), ) }, - [courtBounds, setContent], + [courtBounds, service], ) const renderAvailablePlayerActions = useCallback( @@ -631,7 +443,7 @@ function EditorPage({ playerInfo={info} content={content} courtRef={courtRef} - setContent={setContent} + setContent={service.setContent} /> ), !isFrozen && @@ -646,7 +458,13 @@ function EditorPage({ ), ] }, - [content, courtRef, doMoveBall, previewAction?.isInvalid, setContent], + [ + content, + courtRef, + doMoveBall, + previewAction?.isInvalid, + service.setContent, + ], ) const renderPlayer = useCallback( @@ -713,14 +531,14 @@ function EditorPage({ const doDeleteAction = useCallback( (_: Action, idx: number, origin: TacticComponent) => { - setContent((content) => removeAction(origin, idx, content)) + service.setContent((content) => removeAction(origin, idx, content)) }, - [setContent], + [service], ) const doUpdateAction = useCallback( (component: TacticComponent, action: Action, actionIndex: number) => { - setContent((content) => + service.setContent((content) => updateComponent( { ...component, @@ -734,7 +552,7 @@ function EditorPage({ ), ) }, - [setContent], + [service], ) const renderComponent = useCallback( @@ -749,7 +567,7 @@ function EditorPage({ ball={component} onPosValidated={doMoveBall} onRemove={() => { - setContent((content) => removeBall(content)) + service.setContent((content) => removeBall(content)) setObjects((objects) => [ ...objects, { key: "ball" }, @@ -760,7 +578,7 @@ function EditorPage({ } throw new Error("unknown tactic component " + component) }, - [renderPlayer, doMoveBall, setContent], + [service, renderPlayer, doMoveBall], ) const renderActions = useCallback( @@ -782,7 +600,7 @@ function EditorPage({ /> ) }), - [courtRef, doDeleteAction, doUpdateAction, editorContentCurtainWidth], + [courtRef, doDeleteAction, doUpdateAction], ) const contentNode = ( @@ -809,7 +627,7 @@ function EditorPage({ )} onElementDetached={useCallback( (r, e: RackedCourtObject) => - setContent((content) => + service.setContent((content) => placeObjectAt( r.getBoundingClientRect(), courtBounds(), @@ -817,7 +635,7 @@ function EditorPage({ content, ), ), - [courtBounds, setContent], + [courtBounds, service], )} render={renderCourtObject} /> @@ -846,41 +664,32 @@ function EditorPage({ const stepsTreeNode = ( { - const addedNode = await onAddStep( + const addedNode = await service.addStep( parent, computeTerminalState(content, relativePositions), ) - if (addedNode == null) { - console.error( - "could not add step : onAddStep returned null node", - ) + if (typeof addedNode === "string") { + console.error("could not add step : " + addedNode) return } - selectStep(addedNode.id) - setRootStepsNode((root) => - addStepNode(root, parent, addedNode), - ) + await service.selectStep(addedNode.id) }, - [content, onAddStep, selectStep, relativePositions], + [service, content, relativePositions], )} onRemoveNode={useCallback( async (removed) => { - const isOk = await onRemoveStep(removed) - selectStep(getParent(rootStepsNode, removed)!.id) - if (isOk) - setRootStepsNode( - (root) => removeStepNode(root, removed)!, - ) + await service.removeStep(removed.id) + await service.selectStep(getParent(stepsTree, removed)!.id) }, - [rootStepsNode, onRemoveStep, selectStep], + [service, stepsTree], )} onStepSelected={useCallback( - (node) => selectStep(node.id), - [selectStep], + (node) => service.selectStep(node.id), + [service], )} /> ) @@ -889,7 +698,7 @@ function EditorPage({
- +
{ - onNameChange(new_name).then((success) => { - setTitleStyle(success ? {} : ERROR_STYLE) + service.setName(new_name).then((state) => { + setTitleStyle( + state == SaveStates.Ok + ? {} + : ERROR_STYLE, + ) }) }, - [onNameChange], + [service], )} />
@@ -915,12 +728,12 @@ function EditorPage({
{isStepsTreeVisible ? ( - {contentNode} {stepsTreeNode} - + ) : ( contentNode )} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index faba0d7..38dd127 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -151,7 +151,7 @@ function TitlePersonalSpace() { function TableData({ allTactics }: { allTactics: Tactic[] }) { const nbRow = Math.floor(allTactics.length / 3) + 1 - let listTactic = Array(nbRow) + const listTactic = Array(nbRow) for (let i = 0; i < nbRow; i++) { listTactic[i] = Array(0) } diff --git a/src/service/APITacticService.ts b/src/service/APITacticService.ts new file mode 100644 index 0000000..71f775f --- /dev/null +++ b/src/service/APITacticService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() + } +} diff --git a/src/service/LocalStorageTacticService.ts b/src/service/LocalStorageTacticService.ts new file mode 100644 index 0000000..daf05c4 --- /dev/null +++ b/src/service/LocalStorageTacticService.ts @@ -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({ id: 1, children: [] }), + ) + } + + return new LocalStorageTacticService() + } + + async getContext(): Promise { + 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 { + 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 { + const content = localStorage.getItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, + ) + return content ? JSON.parse(content) : null + } + + async removeStep(id: number): Promise { + 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 { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, + JSON.stringify(content), + ) + } + + async setName(name: string): Promise { + localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) + } +} diff --git a/src/service/TacticService.ts b/src/service/TacticService.ts new file mode 100644 index 0000000..cafa43d --- /dev/null +++ b/src/service/TacticService.ts @@ -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 + + addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise + + removeStep(id: number): Promise + + setName(name: string): Promise + + saveContent( + step: number, + content: StepContent, + ): Promise + + getContent(step: number): Promise +} diff --git a/src/style/editor.css b/src/style/editor.css index 38b7c2b..5ce1a38 100644 --- a/src/style/editor.css +++ b/src/style/editor.css @@ -133,7 +133,6 @@ height: 100%; width: 100%; user-select: none; - -webkit-user-drag: none; } #court-image * { @@ -154,8 +153,6 @@ .save-state, #show-steps-button { user-select: none; - -moz-user-select: none; - -ms-user-select: none; } .save-state-error { diff --git a/test/api/tactics.test.ts b/test/api/tactics.test.ts deleted file mode 100644 index 16f5ac8..0000000 --- a/test/api/tactics.test.ts +++ /dev/null @@ -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) -})