diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 1f831c3..653e0e5 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -174,6 +174,19 @@ - **Review:** The team refined the layout, colors, difficulty display, and accessibility semantics during review. +## Element detail page (`src/components/elements/`, `src/lib/catalog.ts`) + +- **Tool:** GitHub Copilot +- **What the team did:** Provided the detail-page design and specified modular + sections, reuse of the existing variant and difficulty components, existing + CSS colors only, and accessibility requirements. +- **What GitHub Copilot did:** Added a route-backed page, a local catalog data + adapter, reusable header, metadata, diagram and action sections, and tests + for catalog lookup and variant navigation. The page composes the existing + `ElementVariants` and `DifficultyBadge` components. +- **Review:** The team checked the page in the browser, corrected theme-token + use and image sizing, and reviewed keyboard navigation and accessible names. + ## Missing fields in the GraphQL schema (`backend/src/schema.graphql`, `docs/API.md`) - **Tool:** Claude Code, and Claude for the research. diff --git a/package-lock.json b/package-lock.json index 86b1715..2397b46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "lucide-react": "^1.58.0", "pg": "^8.23.1", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@babel/core": "^7.29.7", @@ -115,12 +116,6 @@ "apollo-pbts": "bin/pbts" } }, - "node_modules/@apollo/protobufjs/node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, "node_modules/@apollo/server": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.1.tgz", @@ -1543,12 +1538,25 @@ } } }, - "node_modules/@prisma/client/node_modules/@prisma/client-runtime-utils": { + "node_modules/@prisma/client-runtime-utils": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.1.tgz", "integrity": "sha512-AzoATVIgmO3lcZfM443+9SI7+cyLMW2+Psw4op8WQHpHvHCX//CKxv8fwhiL0yGWF8DjdQrFewxrZo5McBdB8g==", "license": "Apache-2.0" }, + "node_modules/@prisma/config": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.1.tgz", + "integrity": "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "8.0.2", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, "node_modules/@prisma/debug": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.1.tgz", @@ -1595,6 +1603,59 @@ "@prisma/debug": "7.10.1" } }, + "node_modules/@prisma/engines": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.1.tgz", + "integrity": "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1", + "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "@prisma/fetch-engine": "7.10.1", + "@prisma/get-platform": "7.10.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8.tgz", + "integrity": "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", + "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.1.tgz", + "integrity": "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1", + "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "@prisma/get-platform": "7.10.1" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", + "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, "node_modules/@prisma/get-platform": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", @@ -4295,6 +4356,26 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge-ts": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", + "devOptional": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5896,10 +5977,9 @@ } }, "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", "license": "Apache-2.0" }, "node_modules/lru-cache": { @@ -6034,6 +6114,35 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", + "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/named-placeholders": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", @@ -6397,14 +6506,14 @@ } }, "node_modules/pkg-types": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", - "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", + "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.3.1", - "exsolve": "^1.1.1", + "exsolve": "^1.1.3", "pathe": "^2.0.3" } }, @@ -6566,104 +6675,6 @@ } } }, - "node_modules/prisma/node_modules/@prisma/config": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.1.tgz", - "integrity": "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "c12": "3.3.4", - "deepmerge-ts": "8.0.2", - "effect": "3.20.0", - "empathic": "2.0.0" - } - }, - "node_modules/prisma/node_modules/@prisma/engines": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.1.tgz", - "integrity": "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1", - "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", - "@prisma/fetch-engine": "7.10.1", - "@prisma/get-platform": "7.10.1" - } - }, - "node_modules/prisma/node_modules/@prisma/engines-version": { - "version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8.tgz", - "integrity": "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/prisma/node_modules/@prisma/fetch-engine": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.1.tgz", - "integrity": "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1", - "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", - "@prisma/get-platform": "7.10.1" - } - }, - "node_modules/prisma/node_modules/@prisma/get-platform": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", - "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1" - } - }, - "node_modules/prisma/node_modules/deepmerge-ts": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", - "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", - "devOptional": true, - "funding": [ - { - "type": "ko-fi", - "url": "https://ko-fi.com/rebeccastevens" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" - } - ], - "license": "BSD-3-Clause", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/prisma/node_modules/mysql2": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", - "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.2", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.3", - "long": "^5.3.2", - "lru.min": "^1.1.4", - "named-placeholders": "^1.1.6", - "sql-escaper": "^1.5.1" - }, - "engines": { - "node": ">= 8.0" - }, - "peerDependencies": { - "@types/node": ">= 8" - } - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -6823,6 +6834,57 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/readdirp": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", @@ -7050,6 +7112,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7209,9 +7277,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index 30329df..ae196eb 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lucide-react": "^1.58.0", "pg": "^8.23.1", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/src/App.tsx b/src/App.tsx index ef6af75..e85caf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,19 @@ -import './App.css' +import { Navigate, Route, Routes } from 'react-router-dom' +import './index.css' +import './styles/variables.css' +import './styles/global.css' +import ElementDetailPage from './components/elements/ElementDetailPage' function App() { - const iconsUrl = `${import.meta.env.BASE_URL}icons.svg` - return ( -
- -
+ + } + /> + } /> + } /> + ) } diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx new file mode 100644 index 0000000..0d1d8ac --- /dev/null +++ b/src/components/elements/ElementActions.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import { LockKeyhole, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { ElementDetail } from '@/lib/catalog' + +export default function ElementActions({ + element, +}: { + element: ElementDetail +}) { + const [status, setStatus] = useState('') + + return ( +
+ + +

+ {status} +

+
+ ) +} diff --git a/src/components/elements/ElementDetailHeader.tsx b/src/components/elements/ElementDetailHeader.tsx new file mode 100644 index 0000000..7c35d3d --- /dev/null +++ b/src/components/elements/ElementDetailHeader.tsx @@ -0,0 +1,41 @@ +import { ArrowLeft } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import type { ElementDetail } from '@/lib/catalog' + +export default function ElementDetailHeader({ + element, +}: { + element: ElementDetail +}) { + const navigate = useNavigate() + + return ( +
+ +
+

+ {element.code} + {element.name && ( + <> + + {element.name} + + )} +

+ {element.description && ( +

+ {element.description} +

+ )} +
+
+ ) +} diff --git a/src/components/elements/ElementDetailPage.tsx b/src/components/elements/ElementDetailPage.tsx new file mode 100644 index 0000000..e1ec464 --- /dev/null +++ b/src/components/elements/ElementDetailPage.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import ElementActions from '@/components/elements/ElementActions' +import ElementDetailHeader from '@/components/elements/ElementDetailHeader' +import ElementDiagram from '@/components/elements/ElementDiagram' +import ElementMetadataCard from '@/components/elements/ElementMetadataCard' +import ElementVariants from '@/components/ui/ElementVariants' +import { getElementDetail, type ElementDetail } from '@/lib/catalog' + +type DetailState = + | { status: 'loading' } + | { status: 'not-found' } + | { status: 'error' } + | { status: 'success'; element: ElementDetail } + +export default function ElementDetailPage() { + const { elementId } = useParams() + const [retryCount, setRetryCount] = useState(0) + const requestKey = `${elementId ?? ''}:${retryCount}` + const [result, setResult] = useState<{ + requestKey: string + state: DetailState + } | null>(null) + + useEffect(() => { + let isCurrent = true + + getElementDetail(elementId ?? '') + .then((element) => { + if (!isCurrent) return + setResult({ + requestKey, + state: element + ? { status: 'success', element } + : { status: 'not-found' }, + }) + }) + .catch(() => { + if (isCurrent) setResult({ requestKey, state: { status: 'error' } }) + }) + + return () => { + isCurrent = false + } + }, [elementId, requestKey]) + + const state = + result?.requestKey === requestKey + ? result.state + : ({ status: 'loading' } as const) + + if (state.status === 'loading') { + return ( +
+

+ Loading element details +

+
+
+
+
+
+
+ ) + } + + if (state.status === 'not-found') { + return ( +
+
+

Element not found

+

+ No catalog item matches this address. +

+ + View a catalog element + +
+
+ ) + } + + if (state.status === 'error') { + return ( +
+
+

Element details unavailable

+

+ The catalog item could not be loaded. Please try again. +

+ +
+
+ ) + } + + const { element } = state + + return ( +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/elements/ElementDiagram.tsx b/src/components/elements/ElementDiagram.tsx new file mode 100644 index 0000000..1f0ca8a --- /dev/null +++ b/src/components/elements/ElementDiagram.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent } from '@/components/ui/card' +import type { ElementDetail } from '@/lib/catalog' + +export default function ElementDiagram({ + element, +}: { + element: ElementDetail +}) { + return ( +
+ + + {element.imageUrl ? ( + {`Illustration + ) : ( +

+ No illustration is available for this element. +

+ )} +
+
+
+ ) +} diff --git a/src/components/elements/ElementMetadataCard.tsx b/src/components/elements/ElementMetadataCard.tsx new file mode 100644 index 0000000..da87a2c --- /dev/null +++ b/src/components/elements/ElementMetadataCard.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import ElementValueBadge from '@/components/ui/ElementValueBadge' +import type { ElementDetail } from '@/lib/catalog' +import { getDifficultyLetter } from '@/lib/difficulty' + +function MetadataItem({ + label, + children, +}: { + label: string + children: ReactNode +}) { + return ( +
+
+ {label} +
+
+ {children} +
+
+ ) +} + +export default function ElementMetadataCard({ + element, +}: { + element: ElementDetail +}) { + const difficulty = element.isVault + ? undefined + : getDifficultyLetter(element.value) + + return ( +
+ + +
+ + {element.isVault || !difficulty ? ( + + ) : ( + + )} + + + {element.apparatusName} + + + {element.elementGroupName} + +
+
+
+
+ ) +} diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx index 2daf98e..d49d82a 100644 --- a/src/components/ui/DifficultyBadge.tsx +++ b/src/components/ui/DifficultyBadge.tsx @@ -19,7 +19,7 @@ export default function DifficultyBadge({ Difficulty @@ -27,7 +27,7 @@ export default function DifficultyBadge({ {value !== undefined && ,} {value !== undefined && ( - + Value {value.toFixed(1)} diff --git a/src/components/ui/ElementValueBadge.tsx b/src/components/ui/ElementValueBadge.tsx new file mode 100644 index 0000000..049834e --- /dev/null +++ b/src/components/ui/ElementValueBadge.tsx @@ -0,0 +1,16 @@ +export default function ElementValueBadge({ + label, + value, +}: { + label: string + value: number +}) { + const formattedValue = value.toFixed(1) + + return ( + + {label} + {formattedValue} + + ) +} diff --git a/src/components/ui/ElementVariants.test.tsx b/src/components/ui/ElementVariants.test.tsx new file mode 100644 index 0000000..2db38d7 --- /dev/null +++ b/src/components/ui/ElementVariants.test.tsx @@ -0,0 +1,42 @@ +import { renderToString } from 'react-dom/server' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' +import ElementVariants from './ElementVariants' + +describe('ElementVariants', () => { + it('renders navigable variant cards with their values', () => { + const markup = renderToString( + + + , + ) + + expect(markup).toMatch(/Variants of this element.*1.*<\/h2>/) + expect(markup).toContain('href="/elements/UB-1.101-b"') + expect(markup).toContain('Glide kip with half turn') + expect(markup).toContain('Difficulty ') + expect(markup).toContain('Value ') + expect(markup).toContain('0.1') + }) + + it('announces when no other variants exist', () => { + const markup = renderToString( + + + , + ) + + expect(markup).toContain('This element has no other variants.') + }) +}) diff --git a/src/components/ui/ElementVariants.tsx b/src/components/ui/ElementVariants.tsx index 50453e8..7cb74bf 100644 --- a/src/components/ui/ElementVariants.tsx +++ b/src/components/ui/ElementVariants.tsx @@ -1,28 +1,67 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Link } from 'react-router-dom' +import { Card, CardContent, CardTitle } from '@/components/ui/card' import DifficultyBadge from '@/components/ui/DifficultyBadge' -import elementImage from '../../../data/images/BB/BB-1.101-a.webp' +import ElementValueBadge from '@/components/ui/ElementValueBadge' +import type { CatalogVariant } from '@/lib/catalog' +import { getDifficultyLetter } from '@/lib/difficulty' -const ElementVariant = () => { +export default function ElementVariants({ + variants, +}: { + variants: CatalogVariant[] +}) { return ( - - - - Code - Name (if any) - Element variant id (a,b,c) - - - -
-

Element description goes here.

- -
- Balance beam element diagram -
-
+
+

+ Variants of this element ({variants.length}) +

+ {variants.length === 0 ? ( +

+ This element has no other variants. +

+ ) : ( + + )} +
) } - -export default ElementVariant diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts new file mode 100644 index 0000000..13b7d6e --- /dev/null +++ b/src/lib/catalog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { buildImageUrl, getElementDetail } from './catalog' + +describe('getElementDetail', () => { + it('returns catalog metadata, other variants, and the diagram URL', async () => { + const detail = await getElementDetail('UB-1.101-a') + + expect(detail).toMatchObject({ + id: 'UB-1.101-a', + apparatusName: 'Uneven bars', + elementGroupName: 'Mounts', + variants: [{ id: 'UB-1.101-b' }], + }) + expect(detail?.imageUrl).toContain('UB-1.101-a.webp') + }) + + it('returns null when the catalog id does not exist', async () => { + await expect(getElementDetail('BB-0.000-a')).resolves.toBeNull() + }) +}) + +describe('buildImageUrl', () => { + it('has no URL when the record has no image', () => { + expect(buildImageUrl('/', 'UB', undefined)).toBeNull() + expect(buildImageUrl('/', 'UB', null)).toBeNull() + }) + + it('does not double the slash after the base URL', () => { + expect(buildImageUrl('/project2/', 'UB', 'a.webp')).toBe( + '/project2/data/images/UB/a.webp', + ) + expect(buildImageUrl('/', 'UB', 'a.webp')).toBe('/data/images/UB/a.webp') + }) +}) diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts new file mode 100644 index 0000000..e6091e2 --- /dev/null +++ b/src/lib/catalog.ts @@ -0,0 +1,95 @@ +import apparatusRecords from '../../data/apparatus.json' +import elementGroupRecords from '../../data/elementGroups.json' +import elementRecords from '../../data/elements.json' +import variantGroupRecords from '../../data/variantGroups.json' +import vaultRecords from '../../data/vaults.json' + +type ElementRecord = (typeof elementRecords)[number] +type VaultRecord = (typeof vaultRecords)[number] +type CatalogRecord = ElementRecord | VaultRecord + +export type CatalogVariant = { + id: string + code: string + name: string | null + description: string + value: number + isVault: boolean +} + +export type ElementDetail = CatalogVariant & { + apparatusId: string + apparatusName: string + elementGroupName: string + imageUrl: string | null + variants: CatalogVariant[] +} + +const catalogById = new Map( + [...elementRecords, ...vaultRecords].map((record) => [record.id, record]), +) + +const apparatusById = new Map( + apparatusRecords.map((apparatus) => [apparatus.id, apparatus]), +) + +const elementGroupById = new Map( + elementGroupRecords.map((group) => [group.id, group]), +) + +const variantGroupById = new Map( + variantGroupRecords.map((group) => [group.id, group]), +) + +export function buildImageUrl( + baseUrl: string, + apparatusId: string, + image: string | null | undefined, +): string | null { + if (!image) return null + return `${baseUrl.replace(/\/+$/, '')}/data/images/${apparatusId}/${image}` +} + +function toCatalogVariant(record: CatalogRecord): CatalogVariant { + const isVault = 'dValue' in record + + return { + id: record.id, + code: record.code, + name: record.name, + description: record.description, + value: isVault ? record.dValue : record.value, + isVault, + } +} + +export async function getElementDetail( + id: string, +): Promise { + const record = catalogById.get(id) + + if (!record) return null + + const apparatusId = id.slice(0, 2) + const variantGroupId = `${apparatusId}-${record.code}` + const relatedVariantIds = + variantGroupById.get(variantGroupId)?.variantIds ?? [] + const variants = relatedVariantIds + .filter((variantId) => variantId !== id) + .map((variantId) => catalogById.get(variantId)) + .filter((variant): variant is CatalogRecord => variant !== undefined) + .map(toCatalogVariant) + return { + ...toCatalogVariant(record), + apparatusId, + apparatusName: apparatusById.get(apparatusId)?.name ?? apparatusId, + elementGroupName: + elementGroupById.get(record.elementGroupId)?.name ?? 'Element group', + imageUrl: buildImageUrl( + import.meta.env.BASE_URL, + apparatusId, + record.image, + ), + variants, + } +} diff --git a/src/lib/difficulty.ts b/src/lib/difficulty.ts index 153fd0d..3938460 100644 --- a/src/lib/difficulty.ts +++ b/src/lib/difficulty.ts @@ -13,6 +13,13 @@ export const DIFFICULTY_VALUE_BY_LETTER = { export type DifficultyLetter = keyof typeof DIFFICULTY_VALUE_BY_LETTER +// Keyed by the value in tenths, so that 0.1 and 0.10000000000000002 both find A. +const letterByTenths = new Map( + ( + Object.entries(DIFFICULTY_VALUE_BY_LETTER) as [DifficultyLetter, number][] + ).map(([letter, value]) => [Math.round(value * 10), letter]), +) + export function getDifficultyValue(letter: string): number | undefined { const normalizedLetter = letter.toUpperCase() @@ -22,3 +29,9 @@ export function getDifficultyValue(letter: string): number | undefined { return DIFFICULTY_VALUE_BY_LETTER[normalizedLetter as DifficultyLetter] } + +export function getDifficultyLetter( + value: number, +): DifficultyLetter | undefined { + return letterByTenths.get(Math.round(value * 10)) +} diff --git a/src/main.tsx b/src/main.tsx index 17d5695..34e0be6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './index.css' -import './styles/variables.css' -import './styles/global.css' +import { BrowserRouter } from 'react-router-dom' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/styles/global.css b/src/styles/global.css index e69de29..26d7467 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -0,0 +1,3 @@ +#root .element-detail-page :is(h1, h2) { + color: var(--text-color-dark); +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 4ca43f0..099956c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,7 @@ "types": ["vite/client"], "allowArbitraryExtensions": true, "skipLibCheck": true, + "resolveJsonModule": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/vite.config.ts b/vite.config.ts index 8e8ebb6..4fa514a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,25 @@ import tailwindcss from '@tailwindcss/vite' import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' +import { cpSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' +// The drawings in data/images are served by Vite in `npm run dev`, but are not +// in public/, so a build would leave them out of dist/. Copy them there until +// the images come from the server (IMAGE_BASE_URL). +const copyCatalogImages = { + name: 'copy-catalog-images', + apply: 'build' as const, + closeBundle() { + cpSync( + fileURLToPath(new URL('./data/images', import.meta.url)), + fileURLToPath(new URL('./dist/data/images', import.meta.url)), + { recursive: true }, + ) + }, +} + // https://vite.dev/config/ export default defineConfig({ base: '/project2/', @@ -11,6 +27,7 @@ export default defineConfig({ react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss(), + copyCatalogImages, ], resolve: { // "@/components/ui/button" means "src/components/ui/button".