From 165c5ca984b6cff5f6c6659f6fed5a232605ef4c Mon Sep 17 00:00:00 2001 From: maxime Date: Mon, 11 Mar 2024 17:56:20 +0100 Subject: [PATCH] WIP: apply suggestions --- .env | 2 +- .env.TEST | 2 - ci/prepare_php.sh | 9 - src/components/CurtainLayout.tsx | 42 +- src/components/editor/StepsTree.tsx | 34 +- src/editor/ActionsDomains.ts | 2 +- src/editor/StepsDomain.ts | 4 +- src/pages/Editor.tsx | 529 ++++++++--------------- src/service/APITacticService.ts | 84 ++++ src/service/LocalStorageTacticService.ts | 99 +++++ src/service/TacticService.ts | 32 ++ src/style/editor.css | 3 - test/api/tactics.test.ts | 51 --- 13 files changed, 421 insertions(+), 472 deletions(-) delete mode 100644 .env.TEST delete mode 100644 ci/prepare_php.sh create mode 100644 src/service/APITacticService.ts create mode 100644 src/service/LocalStorageTacticService.ts create mode 100644 src/service/TacticService.ts delete mode 100644 test/api/tactics.test.ts diff --git a/.env b/.env index f9d70fc..f288e0f 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ #VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master -VITE_API_ENDPOINT=http://localhost:5254 +VITE_API_ENDPOINT=http://grospc:5254 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/CurtainLayout.tsx index 0be39fb..f147d88 100644 --- a/src/components/CurtainLayout.tsx +++ b/src/components/CurtainLayout.tsx @@ -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] rightWidth: number onRightWidthChange: (w: number) => void @@ -10,12 +10,11 @@ export default function CurtainLayout({ children, rightWidth, onRightWidthChange, -}: SlideLayoutProps) { +}: CurtainLayoutProps) { 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/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index f9c9a07..a0603da 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/StepsDomain.ts b/src/editor/StepsDomain.ts index 15de927..501ff1a 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -47,9 +47,9 @@ export function getStepNode( export function removeStepNode( root: StepInfoNode, - node: StepInfoNode, + node: number, ): StepInfoNode | undefined { - if (root.id === node.id) { + if (root.id === node) { return undefined } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 158441a..ec0e5c5 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 { 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], )} />
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) -})