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 BendableArrow from "../arrows/BendableArrow"
import { useRef } from "react"
import { useRef, useState } from "react"
import AddSvg from "../../assets/icon/add.svg?react"
import RemoveSvg from "../../assets/icon/remove.svg?react"
@ -18,14 +18,21 @@ export default function StepsTree({
onRemoveNode,
onStepSelected,
}: StepsTreeProps) {
const [selectedStepId, setSelectedStepId] = useState(root.id)
return (
<div className="steps-tree">
<StepsTreeNode
node={root}
isNodeRoot={true}
onStepSelected={onStepSelected}
selectedStepId={selectedStepId}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
onStepSelected={(step) => {
setSelectedStepId(step.id)
onStepSelected(step)
}}
/>
</div>
)
@ -34,6 +41,7 @@ export default function StepsTree({
interface StepsTreeContentProps {
node: StepInfoNode
isNodeRoot: boolean
selectedStepId: number,
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
@ -42,28 +50,25 @@ interface StepsTreeContentProps {
function StepsTreeNode({
node,
isNodeRoot,
selectedStepId,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null)
return (
<div ref={ref} className={"step-group"}>
<StepPiece
id={node.id}
onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={
isNodeRoot ? undefined : () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}
/>
<div ref={ref}
className={"step-group"}>
{node.children.map((child) => (
<BendableArrow
key={child.id}
area={ref}
startPos={"step-piece-" + node.id}
segments={[{next: "step-piece-" + child.id}]}
onSegmentsChanges={() => {}}
onSegmentsChanges={() => {
}}
forceStraight={true}
wavy={false}
//TODO remove magic constants
@ -71,11 +76,19 @@ function StepsTreeNode({
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"}>
{node.children.map((child) => (
<StepsTreeNode
key={child.id}
isNodeRoot={false}
selectedStepId={selectedStepId}
node={child}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
@ -89,6 +102,7 @@ function StepsTreeNode({
interface StepPieceProps {
id: number
isSelected: boolean
onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void
onSelected: () => void
@ -96,6 +110,7 @@ interface StepPieceProps {
function StepPiece({
id,
isSelected,
onAddButtonClicked,
onRemoveButtonClicked,
onSelected,
@ -104,7 +119,7 @@ function StepPiece({
<div
id={"step-piece-" + id}
tabIndex={1}
className={"step-piece"}
className={"step-piece " + (isSelected ? "step-piece-selected" : "")}
onClick={onSelected}>
<div className="step-piece-actions">
{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"
import { CourtBall } from "../components/editor/CourtBall"
import { useNavigate, useParams } from "react-router-dom"
import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx"
import StepsTree from "../components/editor/StepsTree"
import { addStepNode, removeStepNode } from "../editor/StepsDomain"
import { addStepNode, getAvailableId, getParent, removeStepNode } from "../editor/StepsDomain"
const ERROR_STYLE: CSSProperties = {
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"
export interface EditorViewProps {
tactic: TacticInfo
onContentChange: (tactic: StepContent) => Promise<SaveState>
onNameChange: (name: string) => Promise<boolean>
}
// The step identifier the editor will always open on
const DEFAULT_STEP_ID = 1
interface TacticDto {
id: number
@ -103,34 +100,103 @@ interface TacticDto {
root: StepInfoNode
}
interface EditorPageProps {
export interface EditorPageProps {
guestMode: boolean
}
export default function EditorPage({ guestMode }: EditorPageProps) {
const [tactic, setTactic] = useState<TacticDto | null>(() => {
if (guestMode) {
return {
id: -1,
courtType: "PLAIN",
content: { components: [] },
name: DEFAULT_TACTIC_NAME,
root: { id: 1, children: [] },
export default function Editor({ guestMode }: EditorPageProps) {
return <EditorPortal guestMode={guestMode} />
}
function EditorPortal({ guestMode }: EditorPageProps) {
return guestMode ? <GuestModeEditor /> : <UserModeEditor />
}
return null
function GuestModeEditor() {
const storageContent = localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0")
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 id = guestMode ? -1 : parseInt(idStr!)
const id = parseInt(idStr!)
const navigation = useNavigate()
useEffect(() => {
if (guestMode) return
async function initialize() {
const infoResponsePromise = fetchAPIGet(`tactics/${id}`)
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 treeResponse = await treeResponsePromise
@ -153,73 +219,14 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
}
initialize()
}, [guestMode, 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
}
}, [id, idStr, navigation])
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(
stepInitialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
useMemo(
() =>
debounceAsync(async (content: StepContent) => {
if (isInGuestMode) {
localStorage.setItem(
GUEST_MODE_CONTENT_STORAGE_KEY,
JSON.stringify(content),
)
return SaveStates.Guest
}
tactic?.content ?? { components: [] },
SaveStates.Ok,
useMemo(() => debounceAsync(async (content: StepContent) => {
const response = await fetchAPI(
`tactics/${id}/steps/${stepId}`,
{
@ -230,83 +237,63 @@ function Editor({
"PUT",
)
return response.ok ? SaveStates.Ok : SaveStates.Err
}, 250),
[id, isInGuestMode, stepId],
),
)
return (
<EditorView
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",
}, 250), [id, stepId]),
)
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 onNameChange = useCallback((name: string) =>
fetchAPI(`tactics/${id}/edit/name`, { name })
.then((r) => r.ok)
, [id])
const response = await fetchAPI(
`tactics/${id}/name`,
{ name },
"PUT",
)
if (response.status == 401) {
navigate("/login")
}
return response.ok
}}
selectStep={async (step) => {
const selectStep = useCallback(async (step: number) => {
const response = await fetchAPIGet(`tactics/${id}/steps/${step}`)
if (!response.ok)
return null
return
setStepId(step)
setStepContent({ ...await response.json() }, false)
}}
onAddStep={async (parent) => {
}, [id, setStepContent])
const onAddStep = useCallback(async (parent: StepInfoNode) => {
const response = await fetchAPI(`tactics/${id}/steps`, {
parentId: parent.id,
})
if (!response.ok) return null
if (!response.ok)
return null
const { stepId } = await response.json()
return { id: stepId, children: [] }
}}
onRemoveStep={async (step) => {
const response = await fetchAPI(
}, [id])
const onRemoveStep = useCallback((step: StepInfoNode) =>
fetchAPI(
`tactics/${id}/steps/${step.id}`,
{},
"DELETE",
)
return response.ok
}}
).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}
/>
)
}
function EditorLoadingScreen() {
return <p>Loading Editor, Please wait...</p>
}
export interface EditorViewProps {
@ -320,11 +307,16 @@ export interface EditorViewProps {
onNameChange: (name: string) => Promise<boolean>
onRemoveStep: (step: StepInfoNode) => Promise<boolean>
onAddStep: (parent: StepInfoNode) => Promise<StepInfoNode | null>
courtType: "PLAIN" | "HALF"
}
function EditorView({
tactic: { name, rootStepNode: initialStepsNode },
function EditorPage({
tactic: {
name,
rootStepNode: initialStepsNode,
courtType,
},
setCurrentStepContent: setContent,
currentStepContent: content,
saveState,
@ -332,8 +324,8 @@ function EditorView({
selectStep,
onRemoveStep,
onAddStep,
courtType,
}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
@ -570,10 +562,10 @@ function EditorView({
const renderComponent = useCallback(
(component: TacticComponent) => {
if (component.type == "player" || component.type == "phantom") {
if (component.type === "player" || component.type === "phantom") {
return renderPlayer(component)
}
if (component.type == BALL_TYPE) {
if (component.type === BALL_TYPE) {
return (
<CourtBall
key="ball"
@ -722,12 +714,12 @@ function EditorView({
onRemoveNode={useCallback(
async (removed) => {
const isOk = await onRemoveStep(removed)
if (isOk)
setRootStepsNode(
selectStep(getParent(rootStepsNode, removed)!.id)
if (isOk) setRootStepsNode(
(root) => removeStepNode(root, removed)!,
)
},
[onRemoveStep],
[rootStepsNode, onRemoveStep, selectStep],
)}
onStepSelected={useCallback(
(node) => selectStep(node.id),
@ -879,7 +871,7 @@ function CourtPlayerArrowAction({
}))
}}
onHeadPicked={(headPos) => {
;(document.activeElement as HTMLElement).blur()
(document.activeElement as HTMLElement).blur()
setPreviewAction({
origin: playerInfo.id,

@ -18,18 +18,21 @@
user-select: none;
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:hover {
background-color: var(--editor-tree-step-piece-hovered);
}
.step-piece:focus-within .step-piece-actions {
visibility: visible;
}
.step-piece:hover .step-piece-actions {
.step-piece-selected .step-piece-actions, .step-piece:hover .step-piece-actions, .step-piece:focus-within .step-piece-actions {
visibility: visible;
}

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

Loading…
Cancel
Save