From 47d81bb665f8b58f6e8ef3ccff49e2d4ca027ba5 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 31 Mar 2024 01:46:39 +0100 Subject: [PATCH] add tactic import and duplication in home page --- .gitignore | 5 +- package.json | 2 + src/App.tsx | 2 +- src/assets/icon/duplicate.svg | 1 + src/components/Visualizer.tsx | 2 +- src/components/editor/BasketCourt.tsx | 20 ++-- src/domains/StepsDomain.ts | 6 +- src/domains/TacticPersistenceDomain.ts | 109 ++++++++++++++++++ src/model/tactic/TacticInfo.ts | 1 - src/pages/Editor.tsx | 4 +- src/pages/HomePage.tsx | 137 ++++++++++++++++++----- src/pages/popup/ExportTacticPopup.tsx | 36 +----- src/service/APITacticService.ts | 4 +- src/service/LocalStorageTacticService.ts | 4 +- src/service/MutableTacticService.ts | 2 +- src/style/home/home.css | 11 +- src/style/home/side_menu.css | 11 +- src/style/template/header.css | 2 + vite.config.ts | 2 + 19 files changed, 269 insertions(+), 92 deletions(-) create mode 100644 src/assets/icon/duplicate.svg create mode 100644 src/domains/TacticPersistenceDomain.ts diff --git a/.gitignore b/.gitignore index 780376a..b97bbea 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ dist-ssr *.sln *.sw? -package-lock.json \ No newline at end of file +package-lock.json + + +stats.html \ No newline at end of file diff --git a/package.json b/package.json index d89f82b..3220415 100644 --- a/package.json +++ b/package.json @@ -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", @@ -34,6 +35,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^24.0.0", "prettier": "^3.1.0", + "rollup-plugin-visualizer": "^5.12.0", "typescript": "^5.2.2", "vite-plugin-svgr": "^4.1.0", "vitest": "^1.3.1" diff --git a/src/App.tsx b/src/App.tsx index 2901b45..350928f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -154,7 +154,7 @@ export default function App() { + , )} /> \ No newline at end of file diff --git a/src/components/Visualizer.tsx b/src/components/Visualizer.tsx index 7a6dad4..2fefe0c 100644 --- a/src/components/Visualizer.tsx +++ b/src/components/Visualizer.tsx @@ -45,7 +45,7 @@ export function Visualizer({ const fetcher = useAppFetcher() const service = useMemo( () => new APITacticService(fetcher, tacticId), - [tacticId], + [fetcher, tacticId], ) const isNotInit = !stepsTree || !courtType diff --git a/src/components/editor/BasketCourt.tsx b/src/components/editor/BasketCourt.tsx index 37596c0..186d2b2 100644 --- a/src/components/editor/BasketCourt.tsx +++ b/src/components/editor/BasketCourt.tsx @@ -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 ( - - ) + return } diff --git a/src/domains/StepsDomain.ts b/src/domains/StepsDomain.ts index 25b21d8..e8208f7 100644 --- a/src/domains/StepsDomain.ts +++ b/src/domains/StepsDomain.ts @@ -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)), } } diff --git a/src/domains/TacticPersistenceDomain.ts b/src/domains/TacticPersistenceDomain.ts new file mode 100644 index 0000000..212b6cf --- /dev/null +++ b/src/domains/TacticPersistenceDomain.ts @@ -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 { + 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 { + const tree = context.stepsTree + + const treeSize = countSteps(tree) + const totalStepsCompleted = new Uint16Array(1) + + async function transformToStep( + stepInfoNode: StepInfoNode, + ): Promise { + 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), + } +} diff --git a/src/model/tactic/TacticInfo.ts b/src/model/tactic/TacticInfo.ts index 0138351..1ec01c8 100644 --- a/src/model/tactic/TacticInfo.ts +++ b/src/model/tactic/TacticInfo.ts @@ -16,7 +16,6 @@ export interface TacticInfo { } export interface TacticStep { - readonly stepId: number readonly content: StepContent readonly children: TacticStep[] } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index aaad380..0b4d560 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -310,11 +310,11 @@ function EditorPageWrapper({ parent: StepInfoNode, content: StepContent, ): Promise { - 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) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 413e88b..b227ec9 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,18 +1,15 @@ import "../style/home/home.css" import { useNavigate } from "react-router-dom" -import { - createContext, - Dispatch, - useContext, - useEffect, useMemo, - useReducer, -} from "react" +import { createContext, Dispatch, useCallback, useContext, 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 @@ -30,9 +27,11 @@ interface Team { enum HomePageStateActionKind { UPDATE_TACTICS = "UPDATE_TACTICS", + ADD_TACTIC = "ADD_TACTIC", + REMOVE_TACTIC = "REMOVE_TACTIC", UPDATE_TEAMS = "UPDATE_TEAMS", SET_EXPORTING_TACTIC = "SET_EXPORTING_TACTIC", - INIT = "INIT" + INIT = "INIT", } type HomePageStateAction = @@ -47,10 +46,18 @@ type HomePageStateAction = | { type: HomePageStateActionKind.INIT state: HomePageState -} | { - type: HomePageStateActionKind.SET_EXPORTING_TACTIC, +} + | { + type: HomePageStateActionKind.SET_EXPORTING_TACTIC tacticId: number | undefined } + | { + type: HomePageStateActionKind.REMOVE_TACTIC, + tacticId: number +} | { + type: HomePageStateActionKind.ADD_TACTIC, + tactic: Tactic +} interface HomePageState { tactics: Tactic[] @@ -75,6 +82,12 @@ function homePageStateReducer( case HomePageStateActionKind.SET_EXPORTING_TACTIC: return { ...state!, exportingTacticId: action.tacticId } + case HomePageStateActionKind.ADD_TACTIC: + return { ...state!, tactics: [action.tactic, ...state.tactics] } + + case HomePageStateActionKind.REMOVE_TACTIC: + return { ...state!, tactics: state.tactics.filter(t => t.id !== action.tacticId) } + case HomePageStateActionKind.INIT: return action.state } @@ -120,20 +133,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 ( - {tacticExportService &&
- dispatch({ type: HomePageStateActionKind.SET_EXPORTING_TACTIC, tacticId: undefined })} - /> -
} + {tacticExportService && ( +
+ + dispatch({ + type: HomePageStateActionKind.SET_EXPORTING_TACTIC, + tacticId: undefined, + }) + } + /> +
+ )}
) @@ -168,11 +191,43 @@ function SideMenu({ width }: { width: number }) {
+
) } +function TacticImportArea() { + const fetcher = useAppFetcher() + const { dispatch } = useHomeState()! + + const handleDrop = useCallback( + async (file: File) => { + importTacticFromFile(fetcher, file, (tactic) => { + dispatch({ + type: HomePageStateActionKind.ADD_TACTIC, + tactic: { + name: tactic.name, + id: tactic.id, + creationDate: new Date().getDate(), + }, + }) + }) + }, + [dispatch, fetcher], + ) + + return ( +
+ +
+ ) +} + function PersonalSpace({ width }: { width: number }) { return (
navigate(`/tactic/${tactic.id}/edit`)} - > -
+ onClick={() => navigate(`/tactic/${tactic.id}/edit`)}> +
t.id !== tactic.id), + type: HomePageStateActionKind.REMOVE_TACTIC, + tacticId: tactic.id, }) }} /> + { + e.stopPropagation() + const service = new APITacticService( + fetcher, + tactic.id, + ) + const context = await service.getContext() + if (typeof context === "string") + throw Error(context) -
+ const plainTactic = await loadPlainTactic( + context, + service, + ) + const { name, id } = await importTactic( + fetcher, + plainTactic, + ) + dispatch({ + type: HomePageStateActionKind.ADD_TACTIC, + tactic: { name, id, creationDate: 0 }, + }) + }} + /> +
) diff --git a/src/pages/popup/ExportTacticPopup.tsx b/src/pages/popup/ExportTacticPopup.tsx index 0e16641..e89dbe3 100644 --- a/src/pages/popup/ExportTacticPopup.tsx +++ b/src/pages/popup/ExportTacticPopup.tsx @@ -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 { - 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( diff --git a/src/service/APITacticService.ts b/src/service/APITacticService.ts index 20df41d..fe2f082 100644 --- a/src/service/APITacticService.ts +++ b/src/service/APITacticService.ts @@ -44,13 +44,13 @@ export class APITacticService implements MutableTacticService { } async addStep( - parent: StepInfoNode, + parentId: number, content: StepContent, ): Promise { const response = await this.fetcher.fetchAPI( `tactics/${this.tacticId}/steps`, { - parentId: parent.id, + parentId: parentId, content, }, ) diff --git a/src/service/LocalStorageTacticService.ts b/src/service/LocalStorageTacticService.ts index 05f3076..d439145 100644 --- a/src/service/LocalStorageTacticService.ts +++ b/src/service/LocalStorageTacticService.ts @@ -55,7 +55,7 @@ export class LocalStorageTacticService implements MutableTacticService { } async addStep( - parent: StepInfoNode, + parentId: number, content: StepContent, ): Promise { 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, diff --git a/src/service/MutableTacticService.ts b/src/service/MutableTacticService.ts index 5c9331d..c39618d 100644 --- a/src/service/MutableTacticService.ts +++ b/src/service/MutableTacticService.ts @@ -23,7 +23,7 @@ export interface TacticService { export interface MutableTacticService extends TacticService { addStep( - parent: StepInfoNode, + parentId: number, content: StepContent, ): Promise diff --git a/src/style/home/home.css b/src/style/home/home.css index 3ec3e19..38821ba 100644 --- a/src/style/home/home.css +++ b/src/style/home/home.css @@ -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; diff --git a/src/style/home/side_menu.css b/src/style/home/side_menu.css index aeb50b4..4beede8 100644 --- a/src/style/home/side_menu.css +++ b/src/style/home/side_menu.css @@ -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%; } diff --git a/src/style/template/header.css b/src/style/template/header.css index 6d64f46..78efc54 100644 --- a/src/style/template/header.css +++ b/src/style/template/header.css @@ -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, diff --git a/vite.config.ts b/vite.config.ts index 8d932e7..c0758f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite" import react from "@vitejs/plugin-react" import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js" import svgr from "vite-plugin-svgr" +import { visualizer } from "rollup-plugin-visualizer" // https://vitejs.dev/config/ export default defineConfig({ @@ -17,5 +18,6 @@ export default defineConfig({ relativeCSSInjection: true, }), svgr(), + visualizer() ], })