diff --git a/.env.example b/.env.example index 3f013e0..e27ba28 100644 --- a/.env.example +++ b/.env.example @@ -50,4 +50,4 @@ WALLS_CDN_BASE=https://huggingface.co/datasets/setrsoft/climbing-walls/resolve/m # --- PostHog analytics --- # Project API Key (Project Settings → Project API Key). Leave empty to disable. VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_xxxxxxxxxxxxxxxxxxxx -VITE_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com +VITE_PUBLIC_POSTHOG_HOST=https://e.setrsoft.com diff --git a/README.md b/README.md index 4bf9c2e..526f4a0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ SetRsoft is an open source community driven software. Test it live on the website http://www.setrsoft.com/ -For better understanding the purpose of this project you can have a look at [one of those videos](https://www.youtube.com/results?search_query=routesetting+in+climbing+gym). +For better understanding the purpose of this project you can have a look at [this video made for 42Builders](https://e.pcloud.link/publink/show?code=XZJuQiZvUY5trMn7pXG09SRvvzBpJmPkl5k). Feel free to create new Github Issues for new features or signal bugs. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60f3ae2..74b42ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,11 +16,10 @@ "posthog-js": "^1.364.7", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-helmet-async": "^3.0.0", "react-i18next": "^16.6.0", "react-router-dom": "^7.13.1", "three": "^0.177.0", - "uuid": "^11.1.0", + "uuid": "^14.0.0", "zustand": "^5.0.4" }, "devDependencies": { @@ -1344,9 +1343,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -1372,9 +1371,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -1390,9 +1389,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@react-three/drei": { @@ -3052,9 +3051,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3655,15 +3654,6 @@ "node": ">=0.8.19" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3725,6 +3715,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4110,18 +4101,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4323,9 +4302,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -4422,22 +4401,22 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -4482,26 +4461,6 @@ "react": "^19.2.4" } }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", - "license": "MIT" - }, - "node_modules/react-helmet-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz", - "integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==", - "license": "Apache-2.0", - "dependencies": { - "invariant": "^2.2.4", - "react-fast-compare": "^3.2.2", - "shallowequal": "^1.1.0" - }, - "peerDependencies": { - "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-i18next": { "version": "16.6.0", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.0.tgz", @@ -4678,12 +4637,6 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5049,22 +5002,22 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index ae470e5..4a3e109 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,11 +18,10 @@ "posthog-js": "^1.364.7", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-helmet-async": "^2.0.1", "react-i18next": "^16.6.0", "react-router-dom": "^7.13.1", "three": "^0.177.0", - "uuid": "^11.1.0", + "uuid": "^14.0.0", "zustand": "^5.0.4" }, "devDependencies": { diff --git a/frontend/src/features/editor/EditorApp.tsx b/frontend/src/features/editor/EditorApp.tsx index 3e98e88..859c0bc 100644 --- a/frontend/src/features/editor/EditorApp.tsx +++ b/frontend/src/features/editor/EditorApp.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useGLTF } from "@react-three/drei"; import { useParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; @@ -6,6 +6,7 @@ import { posthog } from "@/shared/analytics/posthog"; import useWallSessionQuery from "./utils/WallSessionQuery"; import { usePlacementStore } from "./store"; +import type { SessionHoldInstance } from "./store"; import { useHandleLoadSession } from "./utils/HandleLoadSession"; import MainCanvas from "./components/MainCanvas"; @@ -15,15 +16,6 @@ import FileManager from "./components/FileManager"; import { useTranslation } from "react-i18next"; import Tutorial from "./components/Tutorial"; -interface HoldInstance { - id: string; - hold_instance_id?: string; - hold_type?: { - glb_url?: string; - }; - [key: string]: unknown; -} - const transformTools = [ { id: "translate", icon: "open_with", label: "Translate", hint: "Shift + Left click" }, { id: "rotate", icon: "sync", label: "Rotate", hint: "Left click" }, @@ -46,6 +38,8 @@ function EditorApp() { queryKey: ["wallsession", wallId], queryFn: () => fetchWallSession(wallId as string), enabled: !!wallId, + staleTime: Infinity, + refetchOnWindowFocus: false, }); const session_data = data?.wall_session || data; @@ -63,12 +57,10 @@ function EditorApp() { }, [wallId, setObjects, setWallColors, setHoldColors, setColoredTexture, setHasUnsavedChanges]); useEffect(() => { - if (session_data?.id && wallModels.length > 0) { + if (session_data?.id && wallModels.length > 0 && !sessionOpenedRef.current) { + sessionOpenedRef.current = true; handleLoad(); - if (!sessionOpenedRef.current) { - sessionOpenedRef.current = true; - posthog.capture('editor session opened', { wall_id: wallId, session_id: session_data.id }); - } + posthog.capture('editor session opened', { wall_id: wallId, session_id: session_data.id }); } }, [session_data?.id, wallId, wallModels.length, handleLoad]); @@ -84,23 +76,26 @@ function EditorApp() { return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasUnsavedChanges, t]); - const holdModels: Array> = []; - const holdModelsGLBURL: string[] = []; - useEffect(() => { const glbUrl = session_data?.related_wall?.glb_url; setWallModels(glbUrl ? [glbUrl] : []); }, [session_data?.related_wall?.glb_url]); - if (session_data?.related_holds_collection) { - session_data.holds_collection_instances?.forEach((hold: any) => { - hold.hold_instance_id = hold.id; - holdModels.push(hold); - if (hold.hold_type?.glb_url) { - holdModelsGLBURL.push(hold.hold_type.glb_url); - } - }); - } + const { holdModels, holdModelsGLBURL } = useMemo(() => { + const holdModels: SessionHoldInstance[] = []; + const glbUrlSet = new Set(); + if (session_data?.related_holds_collection) { + session_data.holds_collection_instances?.forEach((hold: SessionHoldInstance) => { + const holdWithId = { ...hold, hold_instance_id: hold.id }; + holdModels.push(holdWithId); + if (holdWithId.hold_type?.glb_url) { + glbUrlSet.add(holdWithId.hold_type.glb_url); + } + }); + } + const holdModelsGLBURL = Array.from(glbUrlSet); + return { holdModels, holdModelsGLBURL }; + }, [session_data?.related_holds_collection, session_data?.holds_collection_instances]); const { preload } = useGLTF; useEffect(() => { diff --git a/frontend/src/features/editor/components/AddHoldModal.tsx b/frontend/src/features/editor/components/AddHoldModal.tsx index 8d5d974..4bd4a87 100644 --- a/frontend/src/features/editor/components/AddHoldModal.tsx +++ b/frontend/src/features/editor/components/AddHoldModal.tsx @@ -5,19 +5,19 @@ import HandleAddHold from "../utils/HandleAddHold"; import { useEditorAuth } from "../mocks/useEditorAuth"; import PaginationList from "../stubs/PaginationList"; import { useTranslation } from "react-i18next"; +import type { HoldModel, SessionData } from "../store"; -type HoldModel = { - name: string; - file: string; - hold_type: Record; - hold_instance_id: string; - id?: string; +type StockItem = { + id: string | number; + hold_type: { id: string | number; cdn_ref?: string; [key: string]: unknown }; + model?: string; + [key: string]: unknown; }; interface AddHoldModalProps { isOpen: boolean; onClose: () => void; - session_data: any; + session_data: SessionData; onHoldAdded: (hold: HoldModel) => void; currentHolds: HoldModel[]; } @@ -32,7 +32,7 @@ export default function AddHoldModal({ const [search, setSearch] = useState(""); const [isAdding, setIsAdding] = useState(false); const scrollRef = useRef(null); - const [addedHoldIds, setAddedHoldIds] = useState>(new Set()); + const [addedHoldIds, setAddedHoldIds] = useState>(new Set()); const { user, authenticatedFetch } = useEditorAuth(); const [page, setPage] = useState(1); const API_URL = import.meta.env.VITE_API_BASE; @@ -97,7 +97,7 @@ export default function AddHoldModal({ ); const deduplicatedStockData = Array.isArray(rawStockData) - ? rawStockData.reduce((acc: any[], current: any) => { + ? rawStockData.reduce((acc: StockItem[], current: StockItem) => { const exists = acc.find( (hold) => hold.hold_type.id === current.hold_type.id ); @@ -108,24 +108,25 @@ export default function AddHoldModal({ const stockData = Array.isArray(deduplicatedStockData) ? deduplicatedStockData.filter( - (hold: any) => + (hold: StockItem) => !currentHoldTypeIds.has(hold.hold_type.id) && !addedHoldIds.has(hold.hold_type.id) ) : deduplicatedStockData; const filteredStockData = Array.isArray(stockData) - ? stockData.filter((hold: any) => { + ? stockData.filter((hold: StockItem) => { if (!search) return true; const searchLower = search.toLowerCase(); + const manufacturerName = hold.hold_type?.manufacturer_name as string | undefined; return ( hold.model?.toLowerCase().includes(searchLower) || - hold.hold_type?.manufacturer_name?.toLowerCase().includes(searchLower) + manufacturerName?.toLowerCase().includes(searchLower) ); }) : stockData; - const handleAddHoldClick = async (hold: any) => { + const handleAddHoldClick = async (hold: StockItem) => { setIsAdding(true); try { const result = await HandleAddHold(hold, session_data, onHoldAdded); diff --git a/frontend/src/features/editor/components/FileManager.tsx b/frontend/src/features/editor/components/FileManager.tsx index bfeb3b8..2639a46 100644 --- a/frontend/src/features/editor/components/FileManager.tsx +++ b/frontend/src/features/editor/components/FileManager.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { usePlacementStore } from "../store"; +import type { SessionData } from "../store"; import { useNavigate } from "react-router-dom"; import { useEditorAuth } from "../mocks/useEditorAuth"; import { useTranslation } from "react-i18next"; import { posthog } from "@/shared/analytics/posthog"; -const FileManager = ({ session_data }: { session_data: any }) => { +const FileManager = ({ session_data }: { session_data: SessionData }) => { const objects = usePlacementStore((s) => s.objects); const wallColors = usePlacementStore((s) => s.wallColors); const holdColors = usePlacementStore((s) => s.holdColors); @@ -16,15 +17,16 @@ const FileManager = ({ session_data }: { session_data: any }) => { const navigate = useNavigate(); const { t } = useTranslation(); - const cleanObjectsForSave = (objs: typeof objects, data: any) => { + const cleanObjectsForSave = (objs: typeof objects, data: SessionData) => { return objs.map((obj) => { - const cleanedObj = { ...obj } as any; - if (cleanedObj.type === "wall" && cleanedObj.url?.startsWith("blob:")) { + const cleanedObj: Record = { ...obj }; + const url = cleanedObj.url as string | undefined; + if (cleanedObj.type === "wall" && url?.startsWith("blob:")) { if (data?.related_wall?.id) { cleanedObj.wall_id = data.related_wall.id; } delete cleanedObj.url; - } else if (cleanedObj.url?.startsWith("blob:")) { + } else if (url?.startsWith("blob:")) { delete cleanedObj.url; } return cleanedObj; @@ -49,15 +51,11 @@ const FileManager = ({ session_data }: { session_data: any }) => { alert(t("Layout saved!")); setHasUnsavedChanges(false); - posthog.capture({ - distinctId: 'demo', - event: 'session layout saved', - properties: { session_id: session_data?.id, hold_count: objects.filter((o) => o.type === 'hold').length }, - }); + posthog.capture('session layout saved', { session_id: session_data?.id, hold_count: objects.filter((o) => o.type === 'hold').length }); } catch (err) { alert(t("Error saving layout to server")); console.error(err); - posthog.captureException(err, 'demo', { session_id: session_data?.id }); + posthog.captureException(err, { session_id: session_data?.id }); } }; @@ -78,16 +76,12 @@ const FileManager = ({ session_data }: { session_data: any }) => { } setHasUnsavedChanges(false); - posthog.capture({ - distinctId: 'demo', - event: 'session layout saved and exited', - properties: { session_id: session_data?.id, hold_count: objects.filter((o) => o.type === 'hold').length }, - }); + posthog.capture('session layout saved and exited', { session_id: session_data?.id, hold_count: objects.filter((o) => o.type === 'hold').length }); navigate("/gym"); } catch (err) { alert(t("Error saving. Cannot exit without saving.")); console.error(err); - posthog.captureException(err, 'demo', { session_id: session_data?.id }); + posthog.captureException(err, { session_id: session_data?.id }); } }; @@ -102,15 +96,11 @@ const FileManager = ({ session_data }: { session_data: any }) => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_name: sessionName }), } as RequestInit); - posthog.capture({ - distinctId: 'demo', - event: 'session name updated', - properties: { session_id: session_data?.id }, - }); + posthog.capture('session name updated', { session_id: session_data?.id }); } catch (err) { alert(t("Error updating session name")); console.error(err); - posthog.captureException(err, 'demo', { session_id: session_data?.id }); + posthog.captureException(err, { session_id: session_data?.id }); } })(); }; diff --git a/frontend/src/features/editor/components/HoldInspector.tsx b/frontend/src/features/editor/components/HoldInspector.tsx index 9eafdd2..c7cbb53 100644 --- a/frontend/src/features/editor/components/HoldInspector.tsx +++ b/frontend/src/features/editor/components/HoldInspector.tsx @@ -17,8 +17,8 @@ function RotationHandle({ const handleY = radius + handleRadius * Math.sin(rotation - Math.PI / 2); const onPointerDown = (e: React.PointerEvent) => { e.preventDefault(); - window.addEventListener("pointermove", onPointerMove as any); - window.addEventListener("pointerup", onPointerUp as any); + window.addEventListener("pointermove", onPointerMove as EventListener); + window.addEventListener("pointerup", onPointerUp as EventListener); }; const onPointerMove = (e: PointerEvent) => { if (!circleRef.current) return; @@ -29,8 +29,8 @@ function RotationHandle({ onRotate(angle); }; const onPointerUp = () => { - window.removeEventListener("pointermove", onPointerMove as any); - window.removeEventListener("pointerup", onPointerUp as any); + window.removeEventListener("pointermove", onPointerMove as EventListener); + window.removeEventListener("pointerup", onPointerUp as EventListener); }; return (
{ if (model && pos && quat && alignedQuat) { let customRotation = 0; - if (typeof (model as any).customRotation === "number") { - customRotation = (model as any).customRotation; + if (typeof model.customRotation === "number") { + customRotation = model.customRotation; } else if (model.type === "hold") { customRotation = 0; } @@ -85,7 +84,7 @@ function DragPreview() { const parentObj = objects.find((o) => o.id === parentId); if (parentObj) { // Combine base rotation and customRotation for parent - let parentQ = new THREE.Quaternion(...parentObj.rotation); + const parentQ = new THREE.Quaternion(...parentObj.rotation); if (typeof parentObj.customRotation === "number") { const customQ = new THREE.Quaternion(); customQ.setFromAxisAngle( @@ -112,7 +111,7 @@ function DragPreview() { const match = model.url.match(/\/([^\/]+)\.[^.]+$/); holdName = match ? match[1] : model.url; } - let newId = model.id || uuidv4(); + const newId = model.id || uuidv4(); addObject({ id: newId, type: model.type, @@ -125,11 +124,7 @@ function DragPreview() { name: holdName, }); if (model.type === 'hold') { - posthog.capture({ - distinctId: 'demo', - event: 'hold placed', - properties: { hold_name: holdName, has_parent: !!parentId }, - }); + posthog.capture('hold placed', { hold_name: holdName, has_parent: !!parentId }); } if (selectedObjId) { @@ -159,6 +154,10 @@ function DragPreview() { wallColors, holdColors, coloredTexture, + addObject, + endDrag, + selectObject, + selectedObjId, ]); if (!dragging || !model || !model.url) return null; @@ -204,38 +203,27 @@ function PlacedObjects({ const dragging = useDragStore((s) => s.dragging); const { camera, scene, gl } = useThree(); const [draggingId, setDraggingId] = useState(null); - const [draggedObj, setDraggedObj] = useState(null); - const [draggedChildren, setDraggedChildren] = useState([]); // Ref to track which hold received pointer down const pointerDownHoldIdRef = React.useRef(null); + const draggedObj = useMemo( + () => draggingId ? (objects.find((o) => o.id === draggingId) ?? null) : null, + [draggingId, objects] + ); + const draggedChildren = useMemo( + () => draggedObj ? objects.filter((o) => o.parentId === draggedObj.id).map((o) => o.id) : [], + [draggedObj, objects] + ); + // Find if any hold is selected - const selectedHold = useMemo(() => + const selectedHold = useMemo(() => objects.find((o) => o.type === "hold" && o.id === selectedObjId), [objects, selectedObjId] ); useEffect(() => { if (onHoldDragState) onHoldDragState(!!selectedHold); - }, [selectedHold]); - - // Set dragged object and its children on drag start - useEffect(() => { - if (draggingId) { - const obj = objects.find((o) => o.id === draggingId) || null; - setDraggedObj(obj); - // Store children ids if dragging a parent - if (obj) { - const children = objects - .filter((o) => o.parentId === obj.id) - .map((o) => o.id); - setDraggedChildren(children); - } - } else { - setDraggedObj(null); - setDraggedChildren([]); - } - }, [draggingId, objects]); + }, [selectedHold, onHoldDragState]); // Use unified drag preview logic const { pos: previewPos, quat: previewQuat } = useDragPreview({ @@ -257,7 +245,6 @@ function PlacedObjects({ }); } setDraggingId(null); - setDraggedObj(null); document.body.style.cursor = "grab"; window.removeEventListener("mouseup", handleUp); window.removeEventListener("touchend", handleUp); @@ -283,18 +270,15 @@ function PlacedObjects({ updateObject(childId, { parentId: newParent.id }); }); } - setDraggedChildren([]); } }, [dragging, draggedObj, draggedChildren, objects, updateObject]); // Helper: recursively render hold tree function renderHoldTree(parentId: string | null) { return objects - .filter((o) => o.type === "hold" && o.parentId === parentId && o.url) // Only render objects with valid URLs + .filter((o) => o.type === "hold" && o.parentId === parentId && o.url) .map((obj) => { - // Hide children during parent re-drag if (draggingId && draggingId === obj.id) return null; - // Compose rotation with customRotation if present let groupQuaternion = obj.rotation; if (typeof obj.customRotation === "number") { const baseQ = new THREE.Quaternion(...obj.rotation); @@ -306,13 +290,12 @@ function PlacedObjects({ baseQ.multiply(customQ); groupQuaternion = [baseQ.x, baseQ.y, baseQ.z, baseQ.w]; } - // Timer-based click vs drag logic let dragTimer: number | null = null; let dragStarted = false; const handlePointerDown = (e: React.PointerEvent) => { e.stopPropagation(); dragStarted = false; - pointerDownHoldIdRef.current = obj.id; // Track pointer down hold + pointerDownHoldIdRef.current = obj.id; dragTimer = window.setTimeout(() => { dragStarted = true; removeObject(obj.id); @@ -333,7 +316,6 @@ function PlacedObjects({ clearTimeout(dragTimer); dragTimer = null; } - // Only unselect if pointer down and up happened on the same hold if (!dragStarted && pointerDownHoldIdRef.current === obj.id) { if (selectedObjId === obj.id) { selectObject(null); @@ -426,48 +408,40 @@ function PlacedObjects({ } const MainCanvas = ({ wallModels }: { wallModels: string[] }) => { - const [isWallLoading, setIsWallLoading] = useState(true); const [wallLoadCount, setWallLoadCount] = useState(0); + const [wallBatchSize, setWallBatchSize] = useState(0); + const [forceLoaded, setForceLoaded] = useState(false); - // Add wall by default using the first element from wallModels const addObject = usePlacementStore((s) => s.addObject); const updateObject = usePlacementStore((s) => s.updateObject); const objects = usePlacementStore((s) => s.objects); - // Count wall objects to track loading - const wallObjects = useMemo(() => - objects.filter((o) => o.type === "wall"), + const wallObjects = useMemo(() => + objects.filter((o) => o.type === "wall"), [objects] ); - - useEffect(() => { - if (wallObjects.length > 0) { - setWallLoadCount(0); // Reset counter when walls change - setIsWallLoading(true); - } - }, [wallObjects.length]); - + + // Sync batch size with current wall count — React's recommended pattern for resetting + // state when derived data changes (avoids setState-in-effect anti-pattern). + if (wallBatchSize !== wallObjects.length) { + setWallBatchSize(wallObjects.length); + setWallLoadCount(0); + setForceLoaded(false); + } + + const isWallLoading = !forceLoaded && wallObjects.length > 0 && wallLoadCount < wallObjects.length; + const handleWallLoadComplete = useCallback(() => { setWallLoadCount(prev => prev + 1); }, []); - - useEffect(() => { - if (wallLoadCount >= wallObjects.length && wallObjects.length > 0) { - setIsWallLoading(false); - } - }, [wallLoadCount, wallObjects.length]); - - // If walls are loaded from session but haven't triggered load callbacks, mark as loaded after a delay + + // Fallback: if onLoadComplete never fires (e.g. model cached), stop loading after 1s useEffect(() => { if (wallObjects.length > 0 && isWallLoading) { - const timer = setTimeout(() => { - setIsWallLoading(false); - }, 1000); // Give a short delay for walls that were loaded from session + const timer = setTimeout(() => setForceLoaded(true), 1000); return () => clearTimeout(timer); } }, [wallObjects.length, isWallLoading]); - // Use a ref to track if we've already added a wall to avoid infinite loop - // Reset when wallModels change (new session) const wallAddedRef = useRef(null); useEffect(() => { @@ -513,13 +487,11 @@ const MainCanvas = ({ wallModels }: { wallModels: string[] }) => { console.log("[MainCanvas] Wall already exists, skipping add"); } wallAddedRef.current = currentWallUrl; - setIsWallLoading(false); } } else { console.log("[MainCanvas] No wallModels, clearing wall"); - // No wall available — clear ref and stop loading overlay + // No wall available — clear ref wallAddedRef.current = null; - setIsWallLoading(false); } }, [wallModels, addObject, updateObject, objects]); diff --git a/frontend/src/features/editor/components/ModelViewer.tsx b/frontend/src/features/editor/components/ModelViewer.tsx index a782a6d..7be6bad 100644 --- a/frontend/src/features/editor/components/ModelViewer.tsx +++ b/frontend/src/features/editor/components/ModelViewer.tsx @@ -101,13 +101,17 @@ const ModelViewerInner = ({ let texture: THREE.Texture | null = null; if (Array.isArray(child.material)) { for (const mat of child.material) { - if ((mat as any).map) { - texture = (mat as any).map; + const stdMat = mat as THREE.MeshStandardMaterial; + if (stdMat.map) { + texture = stdMat.map; break; } } - } else if ((child.material as any).map) { - texture = (child.material as any).map; + } else { + const stdMat = child.material as THREE.MeshStandardMaterial; + if (stdMat.map) { + texture = stdMat.map; + } } if (texture) { diff --git a/frontend/src/features/editor/components/Sidebar.tsx b/frontend/src/features/editor/components/Sidebar.tsx index 794a620..881ec33 100644 --- a/frontend/src/features/editor/components/Sidebar.tsx +++ b/frontend/src/features/editor/components/Sidebar.tsx @@ -2,14 +2,7 @@ import SidebarHoldsSection from "./SidebarHoldsSection"; import AddHoldModal from "./AddHoldModal"; import { useState, useRef } from "react"; import { useTranslation } from "react-i18next"; - -type HoldModel = { - name: string; - file: string; - hold_type: Record; - hold_instance_id: string; - id?: string; -}; +import type { HoldModel, SessionData, SessionHoldInstance } from "../store"; const Sidebar = ({ wallModels: _wallModels, @@ -17,18 +10,20 @@ const Sidebar = ({ session_data, }: { wallModels: string[]; - holdModels: Array>; - session_data: any; + holdModels: SessionHoldInstance[]; + session_data: SessionData; }) => { const { t } = useTranslation(); - const processedHoldModels: HoldModel[] = holdModels.map((hold) => ({ - name: hold.hold_type.manufacturer_ref, - file: hold.hold_type.cdn_ref, - hold_type: hold.hold_type, - hold_instance_id: hold.id, - id: hold.id, - })); + const processedHoldModels: HoldModel[] = holdModels + .filter((hold) => !!hold.hold_type) + .map((hold) => ({ + name: (hold.hold_type!.manufacturer_ref as string | undefined) ?? '', + file: (hold.hold_type!.cdn_ref as string | undefined) ?? '', + hold_type: hold.hold_type!, + hold_instance_id: hold.id, + id: hold.id, + })); const holdsSectionRef = useRef<{ addHold: (hold: HoldModel) => void; @@ -36,8 +31,10 @@ const Sidebar = ({ }>(null); const [addHoldModalOpen, setAddHoldModalOpen] = useState(false); + const [locallyAddedHolds, setLocallyAddedHolds] = useState([]); const handleHoldAddedFromModal = (newHold: HoldModel) => { + setLocallyAddedHolds((prev) => [...prev, newHold]); if (holdsSectionRef.current) { holdsSectionRef.current.addHold(newHold); } @@ -58,9 +55,7 @@ const Sidebar = ({ onClose={() => setAddHoldModalOpen(false)} session_data={session_data} onHoldAdded={handleHoldAddedFromModal} - currentHolds={ - holdsSectionRef.current?.getCurrentHolds() || processedHoldModels - } + currentHolds={[...processedHoldModels, ...locallyAddedHolds]} />
diff --git a/frontend/src/features/editor/components/SidebarHoldsSection.tsx b/frontend/src/features/editor/components/SidebarHoldsSection.tsx index 63e487b..eaac310 100644 --- a/frontend/src/features/editor/components/SidebarHoldsSection.tsx +++ b/frontend/src/features/editor/components/SidebarHoldsSection.tsx @@ -2,10 +2,12 @@ import { useState, forwardRef, useImperativeHandle, - useEffect, useRef, + useMemo, + useCallback, } from "react"; import { useDragStore, usePlacementStore } from "../store"; +import type { HoldModel, SessionData } from "../store"; import Hold360, { HoldScrollContext } from "../stubs/Hold360"; import React from "react"; import { useEditorAuth } from "../mocks/useEditorAuth"; @@ -15,14 +17,6 @@ import { useGLTF } from "@react-three/drei"; import { posthog } from "@/shared/analytics/posthog"; import HoldInfoModal from "./HoldInfoModal"; -type HoldModel = { - name: string; - file: string; - hold_type: Record; - hold_instance_id: string; - id?: string; -}; - interface SidebarHoldsSectionRef { addHold: (hold: HoldModel) => void; getCurrentHolds: () => HoldModel[]; @@ -32,27 +26,26 @@ const SidebarHoldsSection = forwardRef< SidebarHoldsSectionRef, { holdModels: HoldModel[]; - session_data: any; + session_data: SessionData; } >(({ holdModels, session_data }, ref) => { - const [models, setModels] = useState(holdModels); + const [locallyAddedHolds, setLocallyAddedHolds] = useState([]); + const [deletedHoldTypeIds, setDeletedHoldTypeIds] = useState>(new Set()); const [showHolds, setShowHolds] = useState(true); const [showVolumes, setShowVolumes] = useState(true); const scrollRef = useRef(null); const [infoHold, setInfoHold] = useState(null); - useEffect(() => { - setModels((prevModels) => { - const locallyAddedHolds = prevModels.filter( - (prevModel) => - !holdModels.some( - (holdModel) => - holdModel.hold_instance_id === prevModel.hold_instance_id - ) - ); - return [...holdModels, ...locallyAddedHolds]; - }); - }, [holdModels]); + const models = useMemo(() => { + const extraHolds = locallyAddedHolds.filter( + (local) => !holdModels.some( + (server) => server.hold_instance_id === local.hold_instance_id + ) + ); + return [...holdModels, ...extraHolds].filter( + (m) => !deletedHoldTypeIds.has(String(m.hold_type?.id)) + ); + }, [holdModels, locallyAddedHolds, deletedHoldTypeIds]); const startDrag = useDragStore((s) => s.startDrag); const endDrag = useDragStore((s) => s.endDrag); @@ -61,7 +54,7 @@ const SidebarHoldsSection = forwardRef< const { t } = useTranslation(); const { user, authenticatedFetch } = useEditorAuth(); const API_URL = import.meta.env.VITE_API_BASE; - const [_currentDownloadUrl, setCurrentDownloadUrl] = useState(); + const [, setCurrentDownloadUrl] = useState(); const { data: stockData } = useQuery({ queryKey: ["stocks", user?.related_gym_id], @@ -73,14 +66,14 @@ const SidebarHoldsSection = forwardRef< enabled: !!user && !!user?.related_gym_id, }); - const addHoldToLocal = (newHold: HoldModel) => { - setModels((prev) => [...prev, newHold]); + const addHoldToLocal = useCallback((newHold: HoldModel) => { + setLocallyAddedHolds((prev) => [...prev, newHold]); if (newHold.hold_type?.glb_url) { useGLTF.preload(newHold.hold_type.glb_url); } - }; + }, []); - const getCurrentHolds = () => models; + const getCurrentHolds = useCallback(() => models, [models]); useImperativeHandle( ref, @@ -88,16 +81,13 @@ const SidebarHoldsSection = forwardRef< addHold: addHoldToLocal, getCurrentHolds, }), - [models] + [addHoldToLocal, getCurrentHolds] ); const handleDelete = async (hold: HoldModel) => { - setModels(models.filter((m) => m.hold_type.id !== hold.hold_type.id)); - posthog.capture({ - distinctId: 'demo', - event: 'hold removed from collection', - properties: { hold_name: hold.name, hold_id: hold.id, session_id: session_data?.id }, - }); + setDeletedHoldTypeIds((prev) => new Set([...prev, String(hold.hold_type?.id)])); + setLocallyAddedHolds((prev) => prev.filter((m) => m.hold_type?.id !== hold.hold_type?.id)); + posthog.capture('hold removed from collection', { hold_name: hold.name, hold_id: hold.id, session_id: session_data?.id }); await authenticatedFetch( `${API_URL}/gym/changeholdtosessioncollection/${session_data.id}/0/${hold.id}/` ); @@ -125,7 +115,7 @@ const SidebarHoldsSection = forwardRef< e.preventDefault(); startDrag({ type: "hold", - url: model.hold_type.glb_url, + url: (model.hold_type.glb_url as string | undefined) ?? '', customRotation: 0, }); const end = () => { @@ -143,13 +133,13 @@ const SidebarHoldsSection = forwardRef< onOpenInfo, }: { hold: HoldModel; - stockData: any; + stockData: { holds?: Array<{ id: string | number; available_count?: number; used_count?: number; color?: string }> } | undefined; onOpenInfo: (hold: HoldModel) => void; }) => { if (!hold || !hold.hold_type) return null; const currentHoldData = stockData?.holds?.find( - (stock: any) => stock.id === hold.id + (stock) => stock.id === hold.id ); const available = currentHoldData?.available_count || 0; const used = currentHoldData?.used_count || 0; diff --git a/frontend/src/features/editor/components/useDragPreview.ts b/frontend/src/features/editor/components/useDragPreview.ts index d925194..27d67d6 100644 --- a/frontend/src/features/editor/components/useDragPreview.ts +++ b/frontend/src/features/editor/components/useDragPreview.ts @@ -9,7 +9,7 @@ function calculateHoldRotation( upVector: "X" | "Y" | "Z" = "Y" ): [number, number, number] { try { - let faceNormal = normal.clone(); + const faceNormal = normal.clone(); faceNormal.transformDirection(surface.matrixWorld); faceNormal.normalize(); if (Math.abs(faceNormal.y) > 0.99) { @@ -53,7 +53,7 @@ function calculateHoldRotation( const finalRotation = new THREE.Euler(); finalRotation.setFromRotationMatrix(finalRotationMatrix); return [finalRotation.x, finalRotation.y, finalRotation.z]; - } catch (error) { + } catch { return [-Math.PI / 2, 0, 0]; } } @@ -160,10 +160,10 @@ export function useDragPreview({ const q = computeAlignedQuaternion(normal, hit.object); setAlignedQuat([q.x, q.y, q.z, q.w]); // Compose with customRotation if present - let finalQ = q.clone(); + const finalQ = q.clone(); let customAngle = 0; - if (model && typeof (model as any).customRotation === "number") { - customAngle = (model as any).customRotation; + if (model && 'customRotation' in model && typeof model.customRotation === "number") { + customAngle = model.customRotation; } if (customAngle) { const customQ = new THREE.Quaternion(); @@ -174,7 +174,7 @@ export function useDragPreview({ // Determine drop target type let foundHoldId: string | undefined = undefined; let foundWall = false; - let o: any = hit.object; + let o: THREE.Object3D | null = hit.object; while (o) { if (o.userData && o.userData.placedObjectId) { foundHoldId = o.userData.placedObjectId; diff --git a/frontend/src/features/editor/store.ts b/frontend/src/features/editor/store.ts index 2e843fc..6a72795 100644 --- a/frontend/src/features/editor/store.ts +++ b/frontend/src/features/editor/store.ts @@ -1,5 +1,46 @@ import { create } from "zustand"; +export interface HoldType { + id?: string | number; + cdn_ref?: string; + manufacturer_ref?: string; + manufacturer?: string | { name?: string; [key: string]: unknown }; + model?: string; + glb_url?: string; + sprite_sheet_url?: string; + hold_usage_type?: string; + [key: string]: unknown; +} + +export interface HoldModel { + name: string; + file: string; + hold_type: HoldType; + hold_instance_id: string; + id?: string; +} + +export type SessionHoldInstance = { + id: string; + hold_instance_id?: string; + hold_type?: HoldType; + [key: string]: unknown; +}; + +export interface SessionData { + id?: string | number; + session_name?: string; + layout?: string | Record; + related_wall?: { + id?: string | number; + glb_url?: string; + [key: string]: unknown; + }; + related_holds_collection?: unknown; + holds_collection_instances?: SessionHoldInstance[]; + [key: string]: unknown; +} + export type DragModel = { type: "wall" | "hold"; url: string; diff --git a/frontend/src/features/editor/stubs/Hold360.tsx b/frontend/src/features/editor/stubs/Hold360.tsx index 7f99a61..cc21702 100644 --- a/frontend/src/features/editor/stubs/Hold360.tsx +++ b/frontend/src/features/editor/stubs/Hold360.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useRef, useState, useEffect } from "react"; +// eslint-disable-next-line react-refresh/only-export-components export const HoldScrollContext = createContext | null>(null); const COLS = 6; @@ -7,6 +8,8 @@ const ROWS = 6; const TOTAL_FRAMES = COLS * ROWS; const FRAME_INTERVAL_MS = 30; +type HoldWithType = { hold_type?: { sprite_sheet_url?: string } }; + export default function Hold360({ cdn_ref: _cdn_ref, hold, @@ -14,11 +17,11 @@ export default function Hold360({ setCurrentDownloadUrl: _setCurrentDownloadUrl, }: { cdn_ref?: string; - hold?: unknown; + hold?: HoldWithType; className?: string; setCurrentDownloadUrl?: (url: string) => void; }) { - const sprite_sheet_url: string | undefined = (hold as any)?.hold_type?.sprite_sheet_url; + const sprite_sheet_url = hold?.hold_type?.sprite_sheet_url; const [isVisible, setIsVisible] = useState(false); const [isLoaded, setIsLoaded] = useState(false); const [frame, setFrame] = useState(0); diff --git a/frontend/src/features/editor/utils/HandleAddHold.tsx b/frontend/src/features/editor/utils/HandleAddHold.tsx index 11660d5..6817242 100644 --- a/frontend/src/features/editor/utils/HandleAddHold.tsx +++ b/frontend/src/features/editor/utils/HandleAddHold.tsx @@ -1,30 +1,22 @@ -interface HoldType { - id: string | number; - cdn_ref: string; - manufacturer_ref?: string; - manufacturer?: string; - model?: string; - glb_url?: string; // provided by the serializer (HF CDN URL) - [key: string]: unknown; -} +import type { HoldType, HoldModel } from "../store"; interface SelectedHold { - id: string; + id: string | number; hold_type: HoldType; } export default async function HandleAddHold( selectedHold: SelectedHold, _session_data: unknown, - onHoldAdded: ((hold: any) => void) | undefined + onHoldAdded: ((hold: HoldModel) => void) | undefined ) { try { - const newHoldInstance = { - hold_instance_id: selectedHold.id, - id: selectedHold.id, - name: selectedHold.hold_type.manufacturer_ref - ?? `${selectedHold.hold_type.manufacturer ?? ''} ${selectedHold.hold_type.model ?? ''}`.trim(), - file: selectedHold.hold_type.cdn_ref, + const newHoldInstance: HoldModel = { + hold_instance_id: String(selectedHold.id), + id: String(selectedHold.id), + name: (selectedHold.hold_type.manufacturer_ref as string | undefined) + ?? `${(selectedHold.hold_type.manufacturer as string | undefined) ?? ''} ${selectedHold.hold_type.model ?? ''}`.trim(), + file: (selectedHold.hold_type.cdn_ref as string) ?? '', hold_type: { ...selectedHold.hold_type, // glb_url is already set by the serializer — no need to reconstruct it diff --git a/frontend/src/features/editor/utils/HandleLoadSession.tsx b/frontend/src/features/editor/utils/HandleLoadSession.tsx index 968c46a..2d082b9 100644 --- a/frontend/src/features/editor/utils/HandleLoadSession.tsx +++ b/frontend/src/features/editor/utils/HandleLoadSession.tsx @@ -1,9 +1,9 @@ import { useCallback } from "react"; import { useEditorAuth } from "../mocks/useEditorAuth"; import { usePlacementStore } from "../store"; +import type { SessionData } from "../store"; - -export const useHandleLoadSession = (session_data: any) => { +export const useHandleLoadSession = (session_data: SessionData) => { const { authenticatedFetch } = useEditorAuth(); const setObjects = usePlacementStore((s) => s.setObjects); @@ -41,9 +41,10 @@ export const useHandleLoadSession = (session_data: any) => { const currentObjects = usePlacementStore.getState().objects; const existingWall = currentObjects.find((obj) => obj.type === "wall"); - const sessionObjects: any[] = data.objects || []; - const sessionWall = sessionObjects.find((obj: any) => obj.type === "wall"); - const sessionHolds = sessionObjects.filter((obj: any) => obj.type !== "wall"); + type LayoutObject = { type?: string; url?: string; wall_id?: string; [key: string]: unknown }; + const sessionObjects: LayoutObject[] = data.objects || []; + const sessionWall = sessionObjects.find((obj) => obj.type === "wall"); + const sessionHolds = sessionObjects.filter((obj) => obj.type !== "wall"); let wallToUse = null; if (sessionWall && sessionWall.url) { @@ -58,7 +59,7 @@ export const useHandleLoadSession = (session_data: any) => { const newObjects = wallToUse ? [wallToUse, ...sessionHolds] : sessionHolds; - setObjects(newObjects); + setObjects(newObjects as import("../store").PlacedObject[]); setWallColors(data.wallColors || {}); setHoldColors(data.holdColors || {}); setColoredTextureState(!!data.coloredTexture); diff --git a/frontend/src/features/editor/utils/useHoldAvailabilityValidation.tsx b/frontend/src/features/editor/utils/useHoldAvailabilityValidation.tsx index d1b837d..8080f2f 100644 --- a/frontend/src/features/editor/utils/useHoldAvailabilityValidation.tsx +++ b/frontend/src/features/editor/utils/useHoldAvailabilityValidation.tsx @@ -1,11 +1,24 @@ import { useEditorAuth } from "../mocks/useEditorAuth"; +type SessionDataWithLayout = { + id?: string | number; + layout?: string | { objects?: Array<{ type?: string; url?: string; [key: string]: unknown }> }; + [key: string]: unknown; +}; + +type StockHold = { + id?: string | number; + hold_type?: { id?: string | number; model?: string; manufacturer_ref?: string; cdn_ref?: string; manufacturer?: string | { name?: string } }; + available_count?: number; + last_used_wall?: { wall_name?: string; session_name?: string; wall_id?: string | number; session_id?: string | number }; +}; + export function useHoldAvailabilityValidation() { const { authenticatedFetch, user } = useEditorAuth(); const API_URL = import.meta.env.VITE_API_BASE; const gym_id = user?.related_gym_id; - const validateHoldAvailability = async (sessionData: any) => { + const validateHoldAvailability = async (sessionData: SessionDataWithLayout) => { try { const stockResponse = await authenticatedFetch( `${API_URL}/gym/stock-explore/${gym_id}/?page_size=1000` @@ -16,7 +29,7 @@ export function useHoldAvailabilityValidation() { } const stockData = await stockResponse.json(); - const stockHolds = stockData.holds || []; + const stockHolds: StockHold[] = stockData.holds || []; let layout = sessionData.layout; if (typeof layout === "string") { @@ -28,12 +41,14 @@ export function useHoldAvailabilityValidation() { } } - if (!layout || !layout.objects || layout.objects.length === 0) { + type ParsedLayout = { objects?: Array<{ type?: string; url?: string; [key: string]: unknown }> }; + const parsedLayout = layout as ParsedLayout | null | undefined; + if (!parsedLayout || !parsedLayout.objects || parsedLayout.objects.length === 0) { return { isValid: true, unavailableHolds: [] }; } const requiredHolds: Record = {}; - layout.objects.forEach((obj: any) => { + parsedLayout.objects.forEach((obj) => { if (obj.type === "hold") { const holdTypeId = extractHoldTypeIdFromUrl(obj.url); if (holdTypeId) { @@ -48,7 +63,7 @@ export function useHoldAvailabilityValidation() { const unavailableHolds = []; for (const [holdTypeId, requiredCount] of Object.entries(requiredHolds)) { - const stockHold = stockHolds.find((h: any) => h.hold_type?.id == holdTypeId); + const stockHold = stockHolds.find((h) => h.hold_type?.id == holdTypeId); if (!stockHold) { unavailableHolds.push({ hold_type_id: holdTypeId, @@ -68,10 +83,11 @@ export function useHoldAvailabilityValidation() { stockHold.hold_type?.manufacturer_ref || stockHold.hold_type?.cdn_ref || `Hold Type ${holdTypeId}`, - manufacturer: - stockHold.hold_type?.manufacturer?.name || - stockHold.hold_type?.manufacturer || - "Unknown", + manufacturer: (() => { + const mfr = stockHold.hold_type?.manufacturer; + if (typeof mfr === 'object' && mfr !== null) return mfr.name ?? "Unknown"; + return (mfr as string | undefined) ?? "Unknown"; + })(), required_count: requiredCount, available_count: availableCount, current_location: @@ -93,7 +109,7 @@ export function useHoldAvailabilityValidation() { } }; - const extractHoldTypeIdFromUrl = (url: string) => { + const extractHoldTypeIdFromUrl = (url: string | undefined) => { if (!url) return null; const match = url.match(/\/gym\/getholdfile\/hold\/(\d+)\//); return match ? parseInt(match[1]) : null; diff --git a/frontend/src/features/showcase/ContactPage.tsx b/frontend/src/features/showcase/ContactPage.tsx index 6586e7d..c469850 100644 --- a/frontend/src/features/showcase/ContactPage.tsx +++ b/frontend/src/features/showcase/ContactPage.tsx @@ -1,5 +1,4 @@ import { useTranslation, Trans } from 'react-i18next'; -import { Helmet } from 'react-helmet-async'; const EMAIL = 'contact@setrsoft.com'; @@ -8,10 +7,8 @@ export function ContactPage() { return ( <> - - Contact – SetRsoft - - + Contact – SetRsoft +

{t('contact.title')}

diff --git a/frontend/src/features/showcase/HomePage.tsx b/frontend/src/features/showcase/HomePage.tsx index 29e6073..406f8cd 100644 --- a/frontend/src/features/showcase/HomePage.tsx +++ b/frontend/src/features/showcase/HomePage.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { Helmet } from 'react-helmet-async'; import { ROUTES } from '@/core/config'; import { posthog } from '@/shared/analytics/posthog'; import demoVideo from '@/assets/demo-CISe8V-c.mp4'; @@ -40,20 +39,18 @@ export function HomePage() { return ( <> - - SetRsoft – Éditeur 3D Bouldering en ligne - - - + SetRsoft – Éditeur 3D Bouldering en ligne + +