add tactic import and duplication in home page
continuous-integration/drone/push Build is passing Details

maxime 1 year ago
parent eef1e16830
commit a1f8ae5017

@ -12,6 +12,7 @@
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-drag-drop-files": "^2.3.10",
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",

@ -154,7 +154,7 @@ export default function App() {
<Route <Route
path={"/tactic/view-guest"} path={"/tactic/view-guest"}
element={suspense( element={suspense(
<VisualizerPage guestMode={true} /> <VisualizerPage guestMode={true} />,
)} )}
/> />
<Route <Route

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m408 112h-224a72 72 0 0 0 -72 72v224a72 72 0 0 0 72 72h224a72 72 0 0 0 72-72v-224a72 72 0 0 0 -72-72zm-32.45 200h-63.55v63.55c0 8.61-6.62 16-15.23 16.43a16 16 0 0 1 -16.77-15.98v-64h-63.55c-8.61 0-16-6.62-16.43-15.23a16 16 0 0 1 15.98-16.77h64v-63.55c0-8.61 6.62-16 15.23-16.43a16 16 0 0 1 16.77 15.98v64h64a16 16 0 0 1 16 16.77c-.42 8.61-7.84 15.23-16.45 15.23z"/><path d="m395.88 80a72.12 72.12 0 0 0 -67.88-48h-224a72 72 0 0 0 -72 72v224a72.12 72.12 0 0 0 48 67.88v-235.88a80 80 0 0 1 80-80z"/></svg>

After

Width:  |  Height:  |  Size: 599 B

@ -30,16 +30,16 @@ export interface ActionPreview extends Action {
} }
export function BasketCourt({ export function BasketCourt({
components, components,
parentComponents, parentComponents,
previewAction, previewAction,
renderComponent, renderComponent,
renderActions, renderActions,
courtImage, courtImage,
courtRef, courtRef,
}: BasketCourtProps) { }: BasketCourtProps) {
const [court, setCourt] = useState(courtRef.current) const [court, setCourt] = useState(courtRef.current)
//force update once the court reference is set //force update once the court reference is set
@ -77,7 +77,5 @@ export function Court({ courtType }: { courtType: CourtType }) {
const CourtSvg = courtType === "PLAIN" ? PlainCourt : HalfCourt const CourtSvg = courtType === "PLAIN" ? PlainCourt : HalfCourt
const courtSpecificClassName = const courtSpecificClassName =
courtType === "PLAIN" ? "plain-court" : "half-court" courtType === "PLAIN" ? "plain-court" : "half-court"
return ( return <CourtSvg className={`court-image ${courtSpecificClassName}`} />
<CourtSvg className={`court-image ${courtSpecificClassName}`} />
)
} }

@ -2,10 +2,10 @@ import { StepInfoNode } from "../model/tactic/TacticInfo.ts"
export function addStepNode( export function addStepNode(
root: StepInfoNode, root: StepInfoNode,
parent: StepInfoNode, parentId: number,
child: StepInfoNode, child: StepInfoNode,
): StepInfoNode { ): StepInfoNode {
if (root.id === parent.id) { if (root.id === parentId) {
return { return {
...root, ...root,
children: root.children.concat(child), children: root.children.concat(child),
@ -14,7 +14,7 @@ export function addStepNode(
return { return {
...root, ...root,
children: root.children.map((c) => addStepNode(c, parent, child)), children: root.children.map((c) => addStepNode(c, parentId, child)),
} }
} }

@ -0,0 +1,109 @@
import { Fetcher } from "../app/Fetcher.ts"
import {
StepInfoNode,
Tactic,
TacticInfo,
TacticStep,
} from "../model/tactic/TacticInfo.ts"
import { APITacticService } from "../service/APITacticService.ts"
import {
TacticContext,
TacticService,
} from "../service/MutableTacticService.ts"
import { countSteps } from "./StepsDomain.ts"
export function importTacticFromFile(
fetcher: Fetcher,
blob: Blob,
onSuccess: (tactic: TacticInfo) => void,
onError: (e: Error | unknown) => void = console.error,
) {
const reader = new FileReader()
reader.onloadend = async (e) => {
const jsonString = e.target!.result as string
let tactic
try {
tactic = await importTactic(fetcher, JSON.parse(jsonString))
} catch (e) {
onError(e)
return
}
onSuccess(tactic)
}
reader.readAsText(blob)
}
export async function importTactic(
fetcher: Fetcher,
tactic: Tactic,
): Promise<TacticInfo> {
const response = await fetcher.fetchAPI(
"tactics",
{
name: tactic.name,
courtType: tactic.courtType,
},
"POST",
)
if (!response.ok) throw Error("Received unsuccessful response from API.")
const { id, rootStepId } = await response.json()
const service = new APITacticService(fetcher, id)
await service.saveContent(rootStepId, tactic.root.content)
async function importStepChildren(parent: TacticStep, parentId: number) {
return await Promise.all(
parent.children.map(async (child) => {
const result = await service.addStep(parentId, child.content)
if (typeof result === "string") throw Error(result)
await importStepChildren(child, result.id)
return result
}),
)
}
const rootStepNode: StepInfoNode = {
id: rootStepId,
children: await importStepChildren(tactic.root, rootStepId),
}
return { courtType: tactic.courtType, name: tactic.name, id, rootStepNode }
}
export async function loadPlainTactic(
context: TacticContext,
service: TacticService,
onProgress: (p: number) => void = () => {},
): Promise<Tactic> {
const tree = context.stepsTree
const treeSize = countSteps(tree)
const totalStepsCompleted = new Uint16Array(1)
async function transformToStep(
stepInfoNode: StepInfoNode,
): Promise<TacticStep> {
const contentResult = await service.getContent(stepInfoNode.id)
if (typeof contentResult === "string") throw Error(contentResult)
Atomics.add(totalStepsCompleted, 0, 1)
onProgress((Atomics.load(totalStepsCompleted, 0) / treeSize) * 100)
return {
content: contentResult,
children: await Promise.all(
stepInfoNode.children.map(transformToStep),
),
}
}
return {
name: context.name,
courtType: context.courtType,
root: await transformToStep(tree),
}
}

@ -16,7 +16,6 @@ export interface TacticInfo {
} }
export interface TacticStep { export interface TacticStep {
readonly stepId: number
readonly content: StepContent readonly content: StepContent
readonly children: TacticStep[] readonly children: TacticStep[]
} }

@ -310,11 +310,11 @@ function EditorPageWrapper({
parent: StepInfoNode, parent: StepInfoNode,
content: StepContent, content: StepContent,
): Promise<StepInfoNode | ServiceError> { ): Promise<StepInfoNode | ServiceError> {
const result = await service.addStep(parent, content) const result = await service.addStep(parent.id, content)
if (typeof result !== "string") { if (typeof result !== "string") {
internalStepsTree = addStepNode( internalStepsTree = addStepNode(
internalStepsTree!, internalStepsTree!,
parent, parent.id,
result, result,
) )
setStepsTree(internalStepsTree) setStepsTree(internalStepsTree)

@ -3,16 +3,25 @@ import { useNavigate } from "react-router-dom"
import { import {
createContext, createContext,
Dispatch, Dispatch,
useCallback,
useContext, useContext,
useEffect, useMemo, useEffect,
useMemo,
useReducer, useReducer,
} from "react" } from "react"
import { useAppFetcher } from "../App.tsx" import { useAppFetcher } from "../App.tsx"
import { Visualizer } from "../components/Visualizer.tsx" import { Visualizer } from "../components/Visualizer.tsx"
import BinSvg from "../assets/icon/bin.svg?react" import BinSvg from "../assets/icon/bin.svg?react"
import ExportSvg from "../assets/icon/export.svg?react" import ExportSvg from "../assets/icon/export.svg?react"
import DuplicateSvg from "../assets/icon/duplicate.svg?react"
import ExportTacticPopup from "./popup/ExportTacticPopup.tsx" import ExportTacticPopup from "./popup/ExportTacticPopup.tsx"
import { APITacticService } from "../service/APITacticService.ts" import { APITacticService } from "../service/APITacticService.ts"
import { FileUploader } from "react-drag-drop-files"
import {
importTactic,
importTacticFromFile,
loadPlainTactic,
} from "../domains/TacticPersistenceDomain.ts"
interface Tactic { interface Tactic {
id: number id: number
@ -32,25 +41,26 @@ enum HomePageStateActionKind {
UPDATE_TACTICS = "UPDATE_TACTICS", UPDATE_TACTICS = "UPDATE_TACTICS",
UPDATE_TEAMS = "UPDATE_TEAMS", UPDATE_TEAMS = "UPDATE_TEAMS",
SET_EXPORTING_TACTIC = "SET_EXPORTING_TACTIC", SET_EXPORTING_TACTIC = "SET_EXPORTING_TACTIC",
INIT = "INIT" INIT = "INIT",
} }
type HomePageStateAction = type HomePageStateAction =
| { | {
type: HomePageStateActionKind.UPDATE_TACTICS type: HomePageStateActionKind.UPDATE_TACTICS
tactics: Tactic[] tactics: Tactic[]
} }
| { | {
type: HomePageStateActionKind.UPDATE_TEAMS type: HomePageStateActionKind.UPDATE_TEAMS
teams: Team[] teams: Team[]
} }
| { | {
type: HomePageStateActionKind.INIT type: HomePageStateActionKind.INIT
state: HomePageState state: HomePageState
} | { }
type: HomePageStateActionKind.SET_EXPORTING_TACTIC, | {
tacticId: number | undefined type: HomePageStateActionKind.SET_EXPORTING_TACTIC
} tacticId: number | undefined
}
interface HomePageState { interface HomePageState {
tactics: Tactic[] tactics: Tactic[]
@ -120,20 +130,30 @@ export default function HomePage() {
initUserData() initUserData()
}, [fetcher, navigate]) }, [fetcher, navigate])
const tacticExportService = useMemo(() => const tacticExportService = useMemo(
state.exportingTacticId ? new APITacticService(fetcher, state.exportingTacticId!) : null () =>
, [fetcher, state.exportingTacticId], state.exportingTacticId
? new APITacticService(fetcher, state.exportingTacticId!)
: null,
[fetcher, state.exportingTacticId],
) )
return ( return (
<HomeStateContext.Provider value={{ state, dispatch }}> <HomeStateContext.Provider value={{ state, dispatch }}>
{tacticExportService && <div id="exports-popup"> {tacticExportService && (
<ExportTacticPopup <div id="exports-popup">
service={tacticExportService} <ExportTacticPopup
show={true} service={tacticExportService}
onHide={() => dispatch({ type: HomePageStateActionKind.SET_EXPORTING_TACTIC, tacticId: undefined })} show={true}
/> onHide={() =>
</div>} dispatch({
type: HomePageStateActionKind.SET_EXPORTING_TACTIC,
tacticId: undefined,
})
}
/>
</div>
)}
<Home /> <Home />
</HomeStateContext.Provider> </HomeStateContext.Provider>
) )
@ -168,11 +188,46 @@ function SideMenu({ width }: { width: number }) {
<div id="side-menu-content"> <div id="side-menu-content">
<LastTeamsSideMenu /> <LastTeamsSideMenu />
<LastTacticsSideMenu /> <LastTacticsSideMenu />
<TacticImportArea />
</div> </div>
</div> </div>
) )
} }
function TacticImportArea() {
const fetcher = useAppFetcher()
const { state, dispatch } = useHomeState()!
const handleDrop = useCallback(
async (file: File) => {
importTacticFromFile(fetcher, file, (tactic) => {
dispatch({
type: HomePageStateActionKind.UPDATE_TACTICS,
tactics: [
{
name: tactic.name,
id: tactic.id,
creationDate: new Date().getDate(),
},
...state.tactics,
],
})
})
},
[dispatch, fetcher, state.tactics],
)
return (
<div id="tactic-import-area">
<FileUploader
handleChange={handleDrop}
types={["json"]}
hoverTitle="Déposez ici"
label="Séléctionnez ou déposez un fichier ici"></FileUploader>
</div>
)
}
function PersonalSpace({ width }: { width: number }) { function PersonalSpace({ width }: { width: number }) {
return ( return (
<div <div
@ -214,10 +269,8 @@ function TacticCard({ tactic }: { tactic: Tactic }) {
return ( return (
<div <div
className={"tactic-card"} className={"tactic-card"}
onClick={() => navigate(`/tactic/${tactic.id}/edit`)} onClick={() => navigate(`/tactic/${tactic.id}/edit`)}>
> <div className={"tactic-card-preview"}>
<div
className={"tactic-card-preview"}>
<Visualizer <Visualizer
visualizerId={tactic.id.toString()} visualizerId={tactic.id.toString()}
tacticId={tactic.id} tacticId={tactic.id}
@ -246,17 +299,49 @@ function TacticCard({ tactic }: { tactic: Tactic }) {
"DELETE", "DELETE",
) )
if (!response.ok) { if (!response.ok) {
throw Error(`Cannot delete tactic ${tactic.id}!`) throw Error(
`Cannot delete tactic ${tactic.id}!`,
)
} }
dispatch({ dispatch({
type: HomePageStateActionKind.UPDATE_TACTICS, type: HomePageStateActionKind.UPDATE_TACTICS,
tactics: tactics.filter((t) => t.id !== tactic.id), tactics: tactics.filter(
(t) => t.id !== tactic.id,
),
}) })
}} }}
/> />
<DuplicateSvg
className="tactic-card-duplicate-btn"
onClick={async (e) => {
e.stopPropagation()
const service = new APITacticService(
fetcher,
tactic.id,
)
const context = await service.getContext()
if (typeof context === "string")
throw Error(context)
</div> const plainTactic = await loadPlainTactic(
context,
service,
)
const { name, id } = await importTactic(
fetcher,
plainTactic,
)
dispatch({
type: HomePageStateActionKind.UPDATE_TACTICS,
tactics: [
{ name, id, creationDate: 0 },
...tactics,
],
})
}}
/>
</div>
</div> </div>
</div> </div>
) )

@ -6,12 +6,7 @@ import { useEffect, useState } from "react"
import "../../style/export_tactic_popup.css" import "../../style/export_tactic_popup.css"
import JsonIcon from "../../assets/icon/json.svg?react" import JsonIcon from "../../assets/icon/json.svg?react"
import { import { loadPlainTactic } from "../../domains/TacticPersistenceDomain.ts"
StepInfoNode,
Tactic,
TacticStep,
} from "../../model/tactic/TacticInfo.ts"
import { countSteps } from "../../domains/StepsDomain.ts"
export interface ExportTacticPopupProps { export interface ExportTacticPopupProps {
service: TacticService service: TacticService
@ -87,34 +82,7 @@ async function exportInJson(
service: TacticService, service: TacticService,
onProgress: (p: number) => void, onProgress: (p: number) => void,
) { ) {
const tree = context.stepsTree const tactic = await loadPlainTactic(context, service, onProgress)
const treeSize = countSteps(tree)
const totalStepsCompleted = new Uint16Array(1)
async function transformToStep(
stepInfoNode: StepInfoNode,
): Promise<TacticStep> {
const contentResult = await service.getContent(stepInfoNode.id)
if (typeof contentResult === "string") throw Error(contentResult)
Atomics.add(totalStepsCompleted, 0, 1)
onProgress((Atomics.load(totalStepsCompleted, 0) / treeSize) * 100)
return {
stepId: stepInfoNode.id,
content: contentResult,
children: await Promise.all(
stepInfoNode.children.map(transformToStep),
),
}
}
const tactic: Tactic = {
name: context.name,
courtType: context.courtType,
root: await transformToStep(tree),
}
const e = document.createElement("a") const e = document.createElement("a")
e.setAttribute( e.setAttribute(

@ -44,13 +44,13 @@ export class APITacticService implements MutableTacticService {
} }
async addStep( async addStep(
parent: StepInfoNode, parentId: number,
content: StepContent, content: StepContent,
): Promise<StepInfoNode | ServiceError> { ): Promise<StepInfoNode | ServiceError> {
const response = await this.fetcher.fetchAPI( const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/steps`, `tactics/${this.tacticId}/steps`,
{ {
parentId: parent.id, parentId: parentId,
content, content,
}, },
) )

@ -55,7 +55,7 @@ export class LocalStorageTacticService implements MutableTacticService {
} }
async addStep( async addStep(
parent: StepInfoNode, parentId: number,
content: StepContent, content: StepContent,
): Promise<StepInfoNode | ServiceError> { ): Promise<StepInfoNode | ServiceError> {
const root: StepInfoNode = JSON.parse( const root: StepInfoNode = JSON.parse(
@ -65,7 +65,7 @@ export class LocalStorageTacticService implements MutableTacticService {
const nodeId = getAvailableId(root) const nodeId = getAvailableId(root)
const node = { id: nodeId, children: [] } const node = { id: nodeId, children: [] }
const resultTree = addStepNode(root, parent, node) const resultTree = addStepNode(root, parentId, node)
localStorage.setItem( localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,

@ -23,7 +23,7 @@ export interface TacticService {
export interface MutableTacticService extends TacticService { export interface MutableTacticService extends TacticService {
addStep( addStep(
parent: StepInfoNode, parentId: number,
content: StepContent, content: StepContent,
): Promise<StepInfoNode | ServiceError> ): Promise<StepInfoNode | ServiceError>

@ -16,6 +16,7 @@
margin: 0; margin: 0;
height: 100%; height: 100%;
background-color: var(--home-second-color); background-color: var(--home-second-color);
user-select: none;
} }
.data { .data {
@ -85,10 +86,13 @@
align-content: center; align-content: center;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
* {
pointer-events: all;
}
} }
.tactic-card-export-btn { .tactic-card-export-btn {
pointer-events: all;
height: 100%; height: 100%;
* { * {
@ -97,7 +101,6 @@
} }
.tactic-card-remove-btn { .tactic-card-remove-btn {
pointer-events: all;
height: 100%; height: 100%;
} }
@ -105,6 +108,10 @@
pointer-events: none !important; pointer-events: none !important;
} }
.tactic-card-duplicate-btn {
fill: #494949;
}
.tactic-card-preview { .tactic-card-preview {
pointer-events: none; pointer-events: none;
display: flex; display: flex;

@ -15,11 +15,19 @@
#side-menu-content { #side-menu-content {
width: 90%; width: 90%;
height: 100%;
display: flex;
flex-direction: column;
gap: 25px;
} }
#tactic-import-area {
height: 100%;
}
.titre-side-menu { .titre-side-menu {
border-bottom: var(--home-main-color) solid 3px; border-bottom: var(--home-main-color) solid 3px;
width: 100%; width: 100%;
margin-bottom: 3%;
} }
#side-menu .title { #side-menu .title {
@ -30,7 +38,6 @@
text-transform: uppercase; text-transform: uppercase;
background-color: var(--home-main-color); background-color: var(--home-main-color);
padding: 3%; padding: 3%;
margin-bottom: 0px;
margin-right: 3%; margin-right: 3%;
} }

@ -1,6 +1,7 @@
@import url(../theme/default.css); @import url(../theme/default.css);
#header { #header {
user-select: none;
text-align: center; text-align: center;
background-color: var(--home-main-color); background-color: var(--home-main-color);
margin: 0; margin: 0;
@ -19,6 +20,7 @@
width: 50px; width: 50px;
height: 50px; height: 50px;
border-radius: 20%; border-radius: 20%;
-webkit-user-drag: none;
} }
#header-left, #header-left,

Loading…
Cancel
Save