Export, Import, Remove, Duplicate and preview tactics #120
home/tactic-management
into master
1 year ago
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.9 KiB |
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.6 KiB |
Before Width: | Height: | Size: 507 B |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 575 B |
After Width: | Height: | Size: 781 B |
After Width: | Height: | Size: 1.6 KiB |
@ -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)
|
||||||||||||
clement.freville2
commented 1 year ago
Review
No need to use atomics as everything happen on the same thread. This work should really be done server side, as it creates a lot of requests and exchanges with the database. Also for the record, you could perform all requests at the same time, regardless of the depth of the node in the tree:
|
|||||||||||||
|
|
||||||||||||
|
return {
|
||||||||||||
|
content: contentResult,
|
||||||||||||
|
children: await Promise.all(
|
||||||||||||
|
stepInfoNode.children.map(transformToStep),
|
||||||||||||
|
),
|
||||||||||||
|
}
|
||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
return {
|
||||||||||||
|
name: context.name,
|
||||||||||||
|
courtType: context.courtType,
|
||||||||||||
|
root: await transformToStep(tree),
|
||||||||||||
|
}
|
||||||||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||||||||
|
import {
|
||||||||||||
|
TacticContext,
|
||||||||||||
|
TacticService,
|
||||||||||||
|
} from "../../service/MutableTacticService.ts"
|
||||||||||||
|
import { useEffect, useState } from "react"
|
||||||||||||
|
import "../../style/export_tactic_popup.css"
|
||||||||||||
|
|
||||||||||||
|
import JsonIcon from "../../assets/icon/json.svg?react"
|
||||||||||||
|
import { loadPlainTactic } from "../../domains/TacticPersistenceDomain.ts"
|
||||||||||||
|
|
||||||||||||
|
export interface ExportTacticPopupProps {
|
||||||||||||
|
service: TacticService
|
||||||||||||
|
onHide: () => void
|
||||||||||||
maxime.batista marked this conversation as resolved
|
|||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
export default function ExportTacticPopup({
|
||||||||||||
|
service,
|
||||||||||||
|
onHide,
|
||||||||||||
|
}: ExportTacticPopupProps) {
|
||||||||||||
|
const [context, setContext] = useState<TacticContext>()
|
||||||||||||
|
const [panicMessage, setPanicMessage] = useState<string>()
|
||||||||||||
|
|
||||||||||||
|
const [exportPercentage, setExportPercentage] = useState(0)
|
||||||||||||
|
|
||||||||||||
|
useEffect(() => {
|
||||||||||||
|
async function init() {
|
||||||||||||
|
const result = await service.getContext()
|
||||||||||||
|
if (typeof result === "string") {
|
||||||||||||
|
setPanicMessage("Could not retrieve tactic context")
|
||||||||||||
|
return
|
||||||||||||
|
}
|
||||||||||||
|
setContext(result)
|
||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
if (!context) init()
|
||||||||||||
|
}, [context, service])
|
||||||||||||
|
|
||||||||||||
|
useEffect(() => {
|
||||||||||||
|
function onKeyUp(e: KeyboardEvent) {
|
||||||||||||
|
if (e.key === "Escape") onHide()
|
||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
window.addEventListener("keyup", onKeyUp)
|
||||||||||||
|
return () => window.removeEventListener("keyup", onKeyUp)
|
||||||||||||
|
}, [onHide])
|
||||||||||||
|
|
||||||||||||
|
if (panicMessage) return <p>{panicMessage}</p>
|
||||||||||||
maxime.batista marked this conversation as resolved
|
|||||||||||||
|
|
||||||||||||
|
return (
|
||||||||||||
|
<div className="popup" onClick={onHide}>
|
||||||||||||
|
<div className="popup-card" onClick={(e) => e.stopPropagation()}>
|
||||||||||||
|
<div className="popup-header">
|
||||||||||||
|
<p>Exporting {context?.name ?? "Tactic"}</p>
|
||||||||||||
|
</div>
|
||||||||||||
|
<ProgressBar percentage={exportPercentage} />
|
||||||||||||
|
<div className="popup-exports">
|
||||||||||||
|
<div
|
||||||||||||
|
className="export-card"
|
||||||||||||
|
onClick={async () => {
|
||||||||||||
|
await exportInJson(
|
||||||||||||
|
context!,
|
||||||||||||
|
service,
|
||||||||||||
|
setExportPercentage,
|
||||||||||||
|
)
|
||||||||||||
|
setExportPercentage(0)
|
||||||||||||
|
}}>
|
||||||||||||
|
<p className="export-card-title">Exporter en JSON</p>
|
||||||||||||
|
<JsonIcon className="json-logo" />
|
||||||||||||
|
</div>
|
||||||||||||
|
</div>
|
||||||||||||
|
</div>
|
||||||||||||
maxime.batista marked this conversation as resolved
|
|||||||||||||
|
</div>
|
||||||||||||
|
)
|
||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
async function exportInJson(
|
||||||||||||
|
context: TacticContext,
|
||||||||||||
|
service: TacticService,
|
||||||||||||
|
onProgress: (p: number) => void,
|
||||||||||||
|
) {
|
||||||||||||
|
const tactic = await loadPlainTactic(context, service, onProgress)
|
||||||||||||
|
|
||||||||||||
|
const e = document.createElement("a")
|
||||||||||||
|
e.setAttribute(
|
||||||||||||
|
"href",
|
||||||||||||
|
"data:application/json;charset=utf-8," +
|
||||||||||||
|
encodeURIComponent(JSON.stringify(tactic, null, 2)),
|
||||||||||||
|
)
|
||||||||||||
|
e.setAttribute("download", `${context.name}.json`)
|
||||||||||||
|
e.style.display = "none"
|
||||||||||||
maxime.batista marked this conversation as resolved
|
|||||||||||||
|
|
||||||||||||
|
document.body.appendChild(e)
|
||||||||||||
|
|
||||||||||||
|
e.click()
|
||||||||||||
|
|
||||||||||||
|
document.body.removeChild(e)
|
||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
interface ProgressBarProps {
|
||||||||||||
|
percentage: number
|
||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
function ProgressBar({ percentage }: ProgressBarProps) {
|
||||||||||||
|
return (
|
||||||||||||
|
<div className={"progressbar"} style={{ display: "flex", height: 3 }}>
|
||||||||||||
|
<div
|
||||||||||||
|
style={{
|
||||||||||||
|
backgroundColor: "#f5992b",
|
||||||||||||
|
width: `${percentage}%`,
|
||||||||||||
|
}}></div>
|
||||||||||||
|
<div
|
||||||||||||
|
style={{
|
||||||||||||
|
backgroundColor: "white",
|
||||||||||||
|
width: `${100 - percentage}%`,
|
||||||||||||
|
}}></div>
|
||||||||||||
|
</div>
|
||||||||||||
|
)
|
||||||||||||
|
}
|
@ -1,24 +1,33 @@
|
|||||||
.court-image-div {
|
|
||||||
position: relative;
|
|
||||||
background-color: white;
|
|
||||||
height: 80vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.court-container {
|
.court-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
background-color: black;
|
background-color: white;
|
||||||
|
padding: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-container:has(.plain-court) {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-container:has(.half-court) {
|
||||||
|
height: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.court-image {
|
.court-image {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background-color: white;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.court-image * {
|
.court-image * {
|
||||||
stroke: var(--selected-team-secondarycolor);
|
stroke: var(--selected-team-secondarycolor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.half-court {
|
||||||
|
max-height: 70vh;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
@ -0,0 +1,65 @@
|
|||||||
|
.popup {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background: rgba(49, 36, 36, 0.53);
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
backdrop-filter: blur(1.7px);
|
||||||
|
-webkit-backdrop-filter: blur(1.7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-card {
|
||||||
|
border: 1px solid rgba(71, 71, 86, 0.72);
|
||||||
|
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-exports {
|
||||||
|
display: flex;
|
||||||
|
align-content: center;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(49, 36, 36, 0.53);
|
||||||
|
backdrop-filter: blur(1.7px);
|
||||||
|
-webkit-backdrop-filter: blur(1.7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-card {
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-card .json-logo {
|
||||||
|
width: 80% !important;
|
||||||
|
height: 80% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-card .json-logo * {
|
||||||
|
fill: #f5992b;
|
||||||
|
}
|
This seems to be quite a huge workaround when the primary issue is that the visualizer use
document
to get the DOM nodes by their id. Maybe update theBendableArrow
component to work only in itsarea
prop?