support steps in guest mode, EditorView refactored

maxime 1 year ago
parent 8804b9c075
commit 90a7177dea

@ -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 { useRef, useState } 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"
@ -13,19 +13,26 @@ export interface StepsTreeProps {
} }
export default function StepsTree({ export default function StepsTree({
root, root,
onAddChildren, onAddChildren,
onRemoveNode, onRemoveNode,
onStepSelected, onStepSelected,
}: StepsTreeProps) { }: StepsTreeProps) {
const [selectedStepId, setSelectedStepId] = useState(root.id)
return ( return (
<div className="steps-tree"> <div className="steps-tree">
<StepsTreeNode <StepsTreeNode
node={root} node={root}
isNodeRoot={true} isNodeRoot={true}
onStepSelected={onStepSelected} selectedStepId={selectedStepId}
onAddChildren={onAddChildren} onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode} onRemoveNode={onRemoveNode}
onStepSelected={(step) => {
setSelectedStepId(step.id)
onStepSelected(step)
}}
/> />
</div> </div>
) )
@ -34,36 +41,34 @@ export default function StepsTree({
interface StepsTreeContentProps { interface StepsTreeContentProps {
node: StepInfoNode node: StepInfoNode
isNodeRoot: boolean isNodeRoot: boolean
selectedStepId: number,
onAddChildren: (parent: StepInfoNode) => void onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void onStepSelected: (node: StepInfoNode) => void
} }
function StepsTreeNode({ function StepsTreeNode({
node, node,
isNodeRoot, isNodeRoot,
onAddChildren, selectedStepId,
onRemoveNode, onAddChildren,
onStepSelected, onRemoveNode,
}: StepsTreeContentProps) { onStepSelected,
}: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
return ( return (
<div ref={ref} className={"step-group"}> <div ref={ref}
<StepPiece className={"step-group"}>
id={node.id}
onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={
isNodeRoot ? undefined : () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}
/>
{node.children.map((child) => ( {node.children.map((child) => (
<BendableArrow <BendableArrow
key={child.id} key={child.id}
area={ref} area={ref}
startPos={"step-piece-" + node.id} startPos={"step-piece-" + node.id}
segments={[{ next: "step-piece-" + child.id }]} segments={[{next: "step-piece-" + child.id}]}
onSegmentsChanges={() => {}} onSegmentsChanges={() => {
}}
forceStraight={true} forceStraight={true}
wavy={false} wavy={false}
//TODO remove magic constants //TODO remove magic constants
@ -71,11 +76,19 @@ function StepsTreeNode({
endRadius={10} endRadius={10}
/> />
))} ))}
<StepPiece
id={node.id}
isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={isNodeRoot ? undefined : () => onRemoveNode(node)}
onSelected={() => onStepSelected(node)}
/>
<div className={"step-children"}> <div className={"step-children"}>
{node.children.map((child) => ( {node.children.map((child) => (
<StepsTreeNode <StepsTreeNode
key={child.id} key={child.id}
isNodeRoot={false} isNodeRoot={false}
selectedStepId={selectedStepId}
node={child} node={child}
onAddChildren={onAddChildren} onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode} onRemoveNode={onRemoveNode}
@ -89,22 +102,24 @@ function StepsTreeNode({
interface StepPieceProps { interface StepPieceProps {
id: number id: number
isSelected: boolean
onAddButtonClicked?: () => void onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void onRemoveButtonClicked?: () => void
onSelected: () => void onSelected: () => void
} }
function StepPiece({ function StepPiece({
id, id,
onAddButtonClicked, isSelected,
onRemoveButtonClicked, onAddButtonClicked,
onSelected, onRemoveButtonClicked,
}: StepPieceProps) { onSelected,
}: StepPieceProps) {
return ( return (
<div <div
id={"step-piece-" + id} id={"step-piece-" + id}
tabIndex={1} tabIndex={1}
className={"step-piece"} className={"step-piece " + (isSelected ? "step-piece-selected" : "")}
onClick={onSelected}> onClick={onSelected}>
<div className="step-piece-actions"> <div className="step-piece-actions">
{onAddButtonClicked && ( {onAddButtonClicked && (

@ -34,3 +34,25 @@ export function removeStepNode(
}), }),
} }
} }
/**
* Returns an available identifier that is not already present into the given node tree
* @param root
*/
export function getAvailableId(root: StepInfoNode): number {
const acc = (root: StepInfoNode): number => Math.max(root.id, ...root.children.map(acc))
return acc(root) + 1
}
export function getParent(root: StepInfoNode, node: StepInfoNode): StepInfoNode | null {
if (root.children.find(n => n.id === node.id))
return root
for (const child of root.children) {
const result = getParent(child, node)
if (result != null) {
return result
}
}
return null
}

@ -78,22 +78,19 @@ import {
} 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 { useNavigate, useParams } from "react-router-dom"
import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx"
import StepsTree from "../components/editor/StepsTree" import StepsTree from "../components/editor/StepsTree"
import { addStepNode, removeStepNode } from "../editor/StepsDomain" import { addStepNode, getAvailableId, getParent, removeStepNode } from "../editor/StepsDomain"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
} }
const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" 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" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export interface EditorViewProps { // The step identifier the editor will always open on
tactic: TacticInfo const DEFAULT_STEP_ID = 1
onContentChange: (tactic: StepContent) => Promise<SaveState>
onNameChange: (name: string) => Promise<boolean>
}
interface TacticDto { interface TacticDto {
id: number id: number
@ -103,34 +100,103 @@ interface TacticDto {
root: StepInfoNode root: StepInfoNode
} }
interface EditorPageProps { export interface EditorPageProps {
guestMode: boolean guestMode: boolean
} }
export default function EditorPage({ guestMode }: EditorPageProps) { export default function Editor({ guestMode }: EditorPageProps) {
const [tactic, setTactic] = useState<TacticDto | null>(() => { return <EditorPortal guestMode={guestMode} />
if (guestMode) { }
return {
id: -1,
courtType: "PLAIN", function EditorPortal({ guestMode }: EditorPageProps) {
content: { components: [] }, return guestMode ? <GuestModeEditor /> : <UserModeEditor />
name: DEFAULT_TACTIC_NAME, }
root: { id: 1, children: [] },
} function GuestModeEditor() {
} const storageContent = localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0")
return null
const stepInitialContent = ({
...(storageContent == null ? { components: [] } : JSON.parse(storageContent)),
stepId: 0,
}) })
// 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: 0, children: [] }))
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId,
JSON.stringify(stepInitialContent),
)
}
const [stepId, setStepId] = useState(DEFAULT_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),
)
return SaveStates.Guest
}, 250), [stepId]),
)
return <EditorPage
tactic={({
id: -1,
rootStepNode: JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!),
name: localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? "Nouvelle Tactique",
courtType: "PLAIN",
})}
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({ ...JSON.parse(localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!) }, false)
return
}, [setStepContent])}
onAddStep={useCallback(async parent => {
const root: StepInfoNode = JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!)
const nodeId = getAvailableId(root)
const node = { id: nodeId, children: [] }
localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify(addStepNode(root, parent, node)))
localStorage.setItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, JSON.stringify({
stepId: node.id,
components: [],
}))
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() {
const [tactic, setTactic] = useState<TacticDto | null>(null)
const { tacticId: idStr } = useParams() const { tacticId: idStr } = useParams()
const id = guestMode ? -1 : parseInt(idStr!) const id = parseInt(idStr!)
const navigation = useNavigate() const navigation = useNavigate()
useEffect(() => { useEffect(() => {
if (guestMode) return
async function initialize() { async function initialize() {
const infoResponsePromise = fetchAPIGet(`tactics/${id}`) const infoResponsePromise = fetchAPIGet(`tactics/${id}`)
const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`)
const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/1`) const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/${DEFAULT_STEP_ID}`)
const infoResponse = await infoResponsePromise const infoResponse = await infoResponsePromise
const treeResponse = await treeResponsePromise const treeResponse = await treeResponsePromise
@ -153,160 +219,81 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
} }
initialize() initialize()
}, [guestMode, id, idStr, navigation]) }, [id, idStr, navigation])
if (tactic) {
return (
<Editor
id={id}
initialStepsNode={tactic.root}
courtType={tactic.courtType}
initialStepContent={tactic.content}
initialName={tactic.name}
initialStepId={1}
/>
)
}
return <EditorLoadingScreen />
}
function EditorLoadingScreen() {
return <div>Loading Editor, please wait...</div>
}
export interface EditorProps {
id: number
initialName: string
courtType: "PLAIN" | "HALF"
initialStepContent: StepContent
initialStepId: number
initialStepsNode: StepInfoNode
}
function Editor({
id,
initialName,
courtType,
initialStepContent,
initialStepId,
initialStepsNode,
}: EditorProps) {
const isInGuestMode = id == -1
const navigate = useNavigate()
const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
const stepInitialContent = {
...(isInGuestMode && storageContent != null
? JSON.parse(storageContent)
: initialStepContent),
}
const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY)
const editorName =
isInGuestMode && storage_name != null ? storage_name : initialName
const [stepId, setStepId] = useState(initialStepId) const [stepId, setStepId] = useState(1)
const [stepContent, setStepContent, saveState] = useContentState( const [stepContent, setStepContent, saveState] = useContentState(
stepInitialContent, tactic?.content ?? { components: [] },
isInGuestMode ? SaveStates.Guest : SaveStates.Ok, SaveStates.Ok,
useMemo( useMemo(() => debounceAsync(async (content: StepContent) => {
() => const response = await fetchAPI(
debounceAsync(async (content: StepContent) => { `tactics/${id}/steps/${stepId}`,
if (isInGuestMode) { {
localStorage.setItem( content: {
GUEST_MODE_CONTENT_STORAGE_KEY, components: content.components,
JSON.stringify(content), },
) },
return SaveStates.Guest "PUT",
} )
const response = await fetchAPI( return response.ok ? SaveStates.Ok : SaveStates.Err
`tactics/${id}/steps/${stepId}`, }, 250), [id, stepId]),
{
content: {
components: content.components,
},
},
"PUT",
)
return response.ok ? SaveStates.Ok : SaveStates.Err
}, 250),
[id, isInGuestMode, stepId],
),
) )
const onNameChange = useCallback((name: string) =>
fetchAPI(`tactics/${id}/edit/name`, { name })
.then((r) => r.ok)
, [id])
const selectStep = useCallback(async (step: number) => {
const response = await fetchAPIGet(`tactics/${id}/steps/${step}`)
if (!response.ok)
return
setStepId(step)
setStepContent({ ...await response.json() }, false)
}, [id, setStepContent])
const onAddStep = useCallback(async (parent: StepInfoNode) => {
const response = await fetchAPI(`tactics/${id}/steps`, {
parentId: parent.id,
})
if (!response.ok)
return null
const { stepId } = await response.json()
return { id: stepId, children: [] }
}, [id])
const onRemoveStep = useCallback((step: StepInfoNode) =>
fetchAPI(
`tactics/${id}/steps/${step.id}`,
{},
"DELETE",
).then(r => r.ok)
, [id])
if (!tactic)
return <EditorLoadingScreen />
return <EditorPage
tactic={({
id,
name: tactic?.name ?? "",
rootStepNode: tactic?.root ?? { id: stepId, children: [] },
courtType: tactic?.courtType,
})}
currentStepId={stepId}
currentStepContent={stepContent}
setCurrentStepContent={(content) => setStepContent(content, true)}
saveState={saveState}
onNameChange={onNameChange}
selectStep={selectStep}
onAddStep={onAddStep}
onRemoveStep={onRemoveStep}
/>
}
return ( function EditorLoadingScreen() {
<EditorView return <p>Loading Editor, Please wait...</p>
tactic={{
name: editorName,
id,
courtType,
rootStepNode: initialStepsNode,
}}
currentStepContent={stepContent}
currentStepId={stepId}
setCurrentStepContent={(content) => setStepContent(content, true)}
courtType={courtType}
saveState={saveState}
onContentChange={async (content: StepContent) => {
if (isInGuestMode) {
localStorage.setItem(
GUEST_MODE_CONTENT_STORAGE_KEY,
JSON.stringify(content),
)
return SaveStates.Guest
}
const response = await fetchAPI(
`tactics/${id}/steps/1`,
{ content },
"PUT",
)
if (response.status == 401) {
navigate("/login")
}
return response.ok ? SaveStates.Ok : SaveStates.Err
}}
onNameChange={async (name: string) => {
if (isInGuestMode) {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}
const response = await fetchAPI(
`tactics/${id}/name`,
{ name },
"PUT",
)
if (response.status == 401) {
navigate("/login")
}
return response.ok
}}
selectStep={async (step) => {
const response = await fetchAPIGet(`tactics/${id}/steps/${step}`)
if (!response.ok)
return null
setStepId(step)
setStepContent({ ...await response.json() }, false)
}}
onAddStep={async (parent) => {
const response = await fetchAPI(`tactics/${id}/steps`, {
parentId: parent.id,
})
if (!response.ok) return null
const { stepId } = await response.json()
return { id: stepId, children: [] }
}}
onRemoveStep={async (step) => {
const response = await fetchAPI(
`tactics/${id}/steps/${step.id}`,
{},
"DELETE",
)
return response.ok
}}
/>
)
} }
export interface EditorViewProps { export interface EditorViewProps {
@ -320,20 +307,25 @@ export interface EditorViewProps {
onNameChange: (name: string) => Promise<boolean> onNameChange: (name: string) => Promise<boolean>
onRemoveStep: (step: StepInfoNode) => Promise<boolean> onRemoveStep: (step: StepInfoNode) => Promise<boolean>
onAddStep: (parent: StepInfoNode) => Promise<StepInfoNode | null> onAddStep: (parent: StepInfoNode) => Promise<StepInfoNode | null>
courtType: "PLAIN" | "HALF"
} }
function EditorView({
tactic: { name, rootStepNode: initialStepsNode },
setCurrentStepContent: setContent, function EditorPage({
currentStepContent: content, tactic: {
saveState, name,
onNameChange, rootStepNode: initialStepsNode,
selectStep, courtType,
onRemoveStep, },
onAddStep, setCurrentStepContent: setContent,
courtType, currentStepContent: content,
}: EditorViewProps) { saveState,
onNameChange,
selectStep,
onRemoveStep,
onAddStep,
}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({}) const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
@ -570,10 +562,10 @@ function EditorView({
const renderComponent = useCallback( const renderComponent = useCallback(
(component: TacticComponent) => { (component: TacticComponent) => {
if (component.type == "player" || component.type == "phantom") { if (component.type === "player" || component.type === "phantom") {
return renderPlayer(component) return renderPlayer(component)
} }
if (component.type == BALL_TYPE) { if (component.type === BALL_TYPE) {
return ( return (
<CourtBall <CourtBall
key="ball" key="ball"
@ -722,12 +714,12 @@ function EditorView({
onRemoveNode={useCallback( onRemoveNode={useCallback(
async (removed) => { async (removed) => {
const isOk = await onRemoveStep(removed) const isOk = await onRemoveStep(removed)
if (isOk) selectStep(getParent(rootStepsNode, removed)!.id)
setRootStepsNode( if (isOk) setRootStepsNode(
(root) => removeStepNode(root, removed)!, (root) => removeStepNode(root, removed)!,
) )
}, },
[onRemoveStep], [rootStepsNode, onRemoveStep, selectStep],
)} )}
onStepSelected={useCallback( onStepSelected={useCallback(
(node) => selectStep(node.id), (node) => selectStep(node.id),
@ -879,7 +871,7 @@ function CourtPlayerArrowAction({
})) }))
}} }}
onHeadPicked={(headPos) => { onHeadPicked={(headPos) => {
;(document.activeElement as HTMLElement).blur() (document.activeElement as HTMLElement).blur()
setPreviewAction({ setPreviewAction({
origin: playerInfo.id, origin: playerInfo.id,

@ -18,18 +18,21 @@
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
border: 2px solid var(--editor-tree-background);
}
.step-piece-selected {
border: 2px solid var(--selection-color-light);
} }
.step-piece-selected,
.step-piece:focus, .step-piece:focus,
.step-piece:hover { .step-piece:hover {
background-color: var(--editor-tree-step-piece-hovered); background-color: var(--editor-tree-step-piece-hovered);
} }
.step-piece:focus-within .step-piece-actions { .step-piece-selected .step-piece-actions, .step-piece:hover .step-piece-actions, .step-piece:focus-within .step-piece-actions {
visibility: visible;
}
.step-piece:hover .step-piece-actions {
visibility: visible; visibility: visible;
} }

@ -13,6 +13,7 @@
--buttons-shadow-color: #a8a8a8; --buttons-shadow-color: #a8a8a8;
--selection-color: #3f7fc4; --selection-color: #3f7fc4;
--selection-color-light: #acd8f8;
--border-color: #ffffff; --border-color: #ffffff;

Loading…
Cancel
Save