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",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-drag-drop-files": "^2.3.10",
"react-draggable": "^4.4.6",
"react-router-dom": "^6.22.0",
"typescript": "^5.2.2",

@ -154,7 +154,7 @@ export default function App() {
<Route
path={"/tactic/view-guest"}
element={suspense(
<VisualizerPage guestMode={true} />
<VisualizerPage guestMode={true} />,
)}
/>
<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({
components,
parentComponents,
previewAction,
components,
parentComponents,
previewAction,
renderComponent,
renderActions,
renderComponent,
renderActions,
courtImage,
courtRef,
}: BasketCourtProps) {
courtImage,
courtRef,
}: BasketCourtProps) {
const [court, setCourt] = useState(courtRef.current)
//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 courtSpecificClassName =
courtType === "PLAIN" ? "plain-court" : "half-court"
return (
<CourtSvg className={`court-image ${courtSpecificClassName}`} />
)
return <CourtSvg className={`court-image ${courtSpecificClassName}`} />
}

@ -2,10 +2,10 @@ import { StepInfoNode } from "../model/tactic/TacticInfo.ts"
export function addStepNode(
root: StepInfoNode,
parent: StepInfoNode,
parentId: number,
child: StepInfoNode,
): StepInfoNode {
if (root.id === parent.id) {
if (root.id === parentId) {
return {
...root,
children: root.children.concat(child),
@ -14,7 +14,7 @@ export function addStepNode(
return {
...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 {
readonly stepId: number
readonly content: StepContent
readonly children: TacticStep[]
}

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

@ -3,16 +3,25 @@ import { useNavigate } from "react-router-dom"
import {
createContext,
Dispatch,
useCallback,
useContext,
useEffect, useMemo,
useEffect,
useMemo,
useReducer,
} from "react"
import { useAppFetcher } from "../App.tsx"
import { Visualizer } from "../components/Visualizer.tsx"
import BinSvg from "../assets/icon/bin.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 { APITacticService } from "../service/APITacticService.ts"
import { FileUploader } from "react-drag-drop-files"
import {
importTactic,
importTacticFromFile,
loadPlainTactic,
} from "../domains/TacticPersistenceDomain.ts"
interface Tactic {
id: number
@ -32,25 +41,26 @@ enum HomePageStateActionKind {
UPDATE_TACTICS = "UPDATE_TACTICS",
UPDATE_TEAMS = "UPDATE_TEAMS",
SET_EXPORTING_TACTIC = "SET_EXPORTING_TACTIC",
INIT = "INIT"
INIT = "INIT",
}
type HomePageStateAction =
| {
type: HomePageStateActionKind.UPDATE_TACTICS
tactics: Tactic[]
}
type: HomePageStateActionKind.UPDATE_TACTICS
tactics: Tactic[]
}
| {
type: HomePageStateActionKind.UPDATE_TEAMS
teams: Team[]
}
type: HomePageStateActionKind.UPDATE_TEAMS
teams: Team[]
}
| {
type: HomePageStateActionKind.INIT
state: HomePageState
} | {
type: HomePageStateActionKind.SET_EXPORTING_TACTIC,
tacticId: number | undefined
}
type: HomePageStateActionKind.INIT
state: HomePageState
}
| {
type: HomePageStateActionKind.SET_EXPORTING_TACTIC
tacticId: number | undefined
}
interface HomePageState {
tactics: Tactic[]
@ -120,20 +130,30 @@ export default function HomePage() {
initUserData()
}, [fetcher, navigate])
const tacticExportService = useMemo(() =>
state.exportingTacticId ? new APITacticService(fetcher, state.exportingTacticId!) : null
, [fetcher, state.exportingTacticId],
const tacticExportService = useMemo(
() =>
state.exportingTacticId
? new APITacticService(fetcher, state.exportingTacticId!)
: null,
[fetcher, state.exportingTacticId],
)
return (
<HomeStateContext.Provider value={{ state, dispatch }}>
{tacticExportService && <div id="exports-popup">
<ExportTacticPopup
service={tacticExportService}
show={true}
onHide={() => dispatch({ type: HomePageStateActionKind.SET_EXPORTING_TACTIC, tacticId: undefined })}
/>
</div>}
{tacticExportService && (
<div id="exports-popup">
<ExportTacticPopup
service={tacticExportService}
show={true}
onHide={() =>
dispatch({
type: HomePageStateActionKind.SET_EXPORTING_TACTIC,
tacticId: undefined,
})
}
/>
</div>
)}
<Home />
</HomeStateContext.Provider>
)
@ -168,11 +188,46 @@ function SideMenu({ width }: { width: number }) {
<div id="side-menu-content">
<LastTeamsSideMenu />
<LastTacticsSideMenu />
<TacticImportArea />
</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 }) {
return (
<div
@ -214,10 +269,8 @@ function TacticCard({ tactic }: { tactic: Tactic }) {
return (
<div
className={"tactic-card"}
onClick={() => navigate(`/tactic/${tactic.id}/edit`)}
>
<div
className={"tactic-card-preview"}>
onClick={() => navigate(`/tactic/${tactic.id}/edit`)}>
<div className={"tactic-card-preview"}>
<Visualizer
visualizerId={tactic.id.toString()}
tacticId={tactic.id}
@ -246,17 +299,49 @@ function TacticCard({ tactic }: { tactic: Tactic }) {
"DELETE",
)
if (!response.ok) {
throw Error(`Cannot delete tactic ${tactic.id}!`)
throw Error(
`Cannot delete tactic ${tactic.id}!`,
)
}
dispatch({
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>
)

@ -6,12 +6,7 @@ import { useEffect, useState } from "react"
import "../../style/export_tactic_popup.css"
import JsonIcon from "../../assets/icon/json.svg?react"
import {
StepInfoNode,
Tactic,
TacticStep,
} from "../../model/tactic/TacticInfo.ts"
import { countSteps } from "../../domains/StepsDomain.ts"
import { loadPlainTactic } from "../../domains/TacticPersistenceDomain.ts"
export interface ExportTacticPopupProps {
service: TacticService
@ -87,34 +82,7 @@ async function exportInJson(
service: TacticService,
onProgress: (p: number) => void,
) {
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 {
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 tactic = await loadPlainTactic(context, service, onProgress)
const e = document.createElement("a")
e.setAttribute(

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

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

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

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

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

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

Loading…
Cancel
Save