feat(locker): add command palette search
- Add LockerSearch component with Search button and ⌘K shortcut - Add search.ts with buildSearchEntries, buildAssetStats, resolveAssetRef - Add command.tsx shadcn component (cmdk dependency) - Group search results by asset type with quantities and location chips - Show stats: slot, capacity, ammo, weight, NVG generation, rarity - Support navigation: weapon → customize, attachment → parent customize - Add locker-search-highlight animation for 3s amber pulse feedback - Add cmdk@^1.1.1 dependency
This commit is contained in:
parent
6e717684fb
commit
a466ed54ed
7 changed files with 1239 additions and 365 deletions
39
bun.lock
39
bun.lock
|
|
@ -27,6 +27,7 @@
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
"graphql": "^16.12.0",
|
"graphql": "^16.12.0",
|
||||||
|
|
@ -1106,6 +1107,8 @@
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
@ -2636,6 +2639,14 @@
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||||
|
|
||||||
"dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||||
|
|
@ -3054,6 +3065,30 @@
|
||||||
|
|
||||||
"amazon-cognito-identity-js/@aws-crypto/sha256-js/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
"amazon-cognito-identity-js/@aws-crypto/sha256-js/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||||
|
|
||||||
"eslint-plugin-react-hooks/@babel/core/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "7.28.4", "@babel/types": "7.28.4", "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31", "jsesc": "3.1.0" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
|
"eslint-plugin-react-hooks/@babel/core/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "7.28.4", "@babel/types": "7.28.4", "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31", "jsesc": "3.1.0" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
|
||||||
|
|
||||||
"eslint-plugin-react-hooks/@babel/core/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/generator": "7.28.3", "@babel/helper-globals": "7.28.0", "@babel/parser": "7.28.4", "@babel/template": "7.27.2", "@babel/types": "7.28.4", "debug": "4.4.3" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
|
"eslint-plugin-react-hooks/@babel/core/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/generator": "7.28.3", "@babel/helper-globals": "7.28.0", "@babel/parser": "7.28.4", "@babel/template": "7.27.2", "@babel/types": "7.28.4", "debug": "4.4.3" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
|
||||||
|
|
@ -3214,6 +3249,10 @@
|
||||||
|
|
||||||
"@radix-ui/react-toolbar/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
"@radix-ui/react-toolbar/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-accessible-icon/@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
"@radix-ui/react-accessible-icon/@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
"graphql": "^16.12.0",
|
"graphql": "^16.12.0",
|
||||||
|
|
|
||||||
1038
pnpm-lock.yaml
1038
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -285,4 +285,20 @@
|
||||||
|
|
||||||
.org-tree-child:only-child::after {
|
.org-tree-child:only-child::after {
|
||||||
display: none;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locker search-result highlight */
|
||||||
|
@keyframes locker-search-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgb(251 191 36 / 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px rgb(251 191 36 / 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.locker-search-highlight {
|
||||||
|
animation: locker-search-pulse 1.1s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
|
||||||
}
|
}
|
||||||
146
src/components/frontend/locker/LockerSearch.tsx
Normal file
146
src/components/frontend/locker/LockerSearch.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Crosshair, Layers, Package, Palette, SearchIcon } from "lucide-react";
|
||||||
|
import type { Asset, Loadout } from "@/payload-types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
|
||||||
|
import {
|
||||||
|
buildSearchEntries,
|
||||||
|
type LockerSearchEntry,
|
||||||
|
type LockerSearchLocationKind,
|
||||||
|
} from "@/lib/locker/search";
|
||||||
|
import { getAssetIcon } from "./LockerGrid";
|
||||||
|
|
||||||
|
function locationIcon(kind: LockerSearchLocationKind) {
|
||||||
|
switch (kind) {
|
||||||
|
case "locker":
|
||||||
|
return <Package className="size-3 text-muted-foreground" />;
|
||||||
|
case "attached":
|
||||||
|
return <Crosshair className="size-3 text-muted-foreground" />;
|
||||||
|
case "skin":
|
||||||
|
return <Palette className="size-3 text-muted-foreground" />;
|
||||||
|
case "loadout":
|
||||||
|
return <Layers className="size-3 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entrySearchText(entry: LockerSearchEntry): string {
|
||||||
|
return [
|
||||||
|
entry.displayName,
|
||||||
|
entry.asset.name,
|
||||||
|
getAssetTypeLabel(entry.assetType),
|
||||||
|
...entry.locations.map((l) => l.text),
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LockerSearchProps {
|
||||||
|
items: LockerGridItem[];
|
||||||
|
loadouts: Loadout[];
|
||||||
|
onSelect: (entry: LockerSearchEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LockerSearch({ items, loadouts, onSelect }: LockerSearchProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const entries = useMemo(() => buildSearchEntries(items, loadouts), [items, loadouts]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const byType = new Map<Asset["assetType"], LockerSearchEntry[]>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const list = byType.get(entry.assetType) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
byType.set(entry.assetType, list);
|
||||||
|
}
|
||||||
|
return [...byType.entries()].sort((a, b) =>
|
||||||
|
getAssetTypeLabel(a[0]).localeCompare(getAssetTypeLabel(b[0])),
|
||||||
|
);
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<SearchIcon className="size-3.5" />
|
||||||
|
Search
|
||||||
|
<kbd className="ml-1 inline-flex h-4 items-center gap-0.5 rounded border border-border bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen} title="Search Locker">
|
||||||
|
<CommandInput placeholder="Search your locker, attachments, skins, and loadouts..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No items found.</CommandEmpty>
|
||||||
|
{groups.map(([assetType, typeEntries]) => (
|
||||||
|
<CommandGroup key={assetType} heading={getAssetTypeLabel(assetType)}>
|
||||||
|
{typeEntries.map((entry) => (
|
||||||
|
<CommandItem
|
||||||
|
key={entry.key}
|
||||||
|
value={entrySearchText(entry)}
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onSelect(entry);
|
||||||
|
}}
|
||||||
|
className="items-start py-2.5"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 text-amber-400">
|
||||||
|
{getAssetIcon(entry.assetType, "size-4")}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium truncate">{entry.displayName}</span>
|
||||||
|
{entry.quantity > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{entry.quantity.toLocaleString()}×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.stats.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{entry.stats.map((s) => `${s.label}: ${s.value}`).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.locations.length > 0 && (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
|
{entry.locations.map((loc) => (
|
||||||
|
<span
|
||||||
|
key={loc.text}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground/80"
|
||||||
|
>
|
||||||
|
{locationIcon(loc.kind)}
|
||||||
|
{loc.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("overflow-hidden p-0", className)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
180
src/lib/locker/search.ts
Normal file
180
src/lib/locker/search.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import type { Asset, Loadout } from "@/payload-types";
|
||||||
|
import {
|
||||||
|
WARDROBE_SLOT_LAYOUT,
|
||||||
|
getAssetTypeLabel,
|
||||||
|
getLoadoutSlot,
|
||||||
|
type LoadoutSlots,
|
||||||
|
type LockerGridItem,
|
||||||
|
} from "@/lib/locker";
|
||||||
|
|
||||||
|
export type LockerSearchLocationKind = "locker" | "attached" | "skin" | "loadout";
|
||||||
|
|
||||||
|
export interface LockerSearchLocation {
|
||||||
|
kind: LockerSearchLocationKind;
|
||||||
|
text: string;
|
||||||
|
/** Grid entry id of the parent item this entry is attached to / applied on. */
|
||||||
|
parentEntryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LockerSearchStat {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LockerSearchEntry {
|
||||||
|
key: string;
|
||||||
|
assetId: number;
|
||||||
|
asset: Asset;
|
||||||
|
assetType: Asset["assetType"];
|
||||||
|
displayName: string;
|
||||||
|
quantity: number;
|
||||||
|
stats: LockerSearchStat[];
|
||||||
|
locations: LockerSearchLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RARITY_LABELS: Record<string, string> = {
|
||||||
|
basic: "Basic",
|
||||||
|
common: "Common",
|
||||||
|
uncommon: "Uncommon",
|
||||||
|
rare: "Rare",
|
||||||
|
veryRare: "Very Rare",
|
||||||
|
epic: "Epic",
|
||||||
|
legendary: "Legendary",
|
||||||
|
unique: "Unique",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NVG_GEN_LABELS: Record<string, string> = {
|
||||||
|
gen1: "Gen 1",
|
||||||
|
gen2: "Gen 2",
|
||||||
|
gen3: "Gen 3",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveAssetRef(
|
||||||
|
value: number | Asset | null | undefined,
|
||||||
|
items: LockerGridItem[],
|
||||||
|
): Asset | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (typeof value === "object" && "id" in value) return value as Asset;
|
||||||
|
return items.find((i) => i.assetId === value)?.asset ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssetStats(asset: Asset): LockerSearchStat[] {
|
||||||
|
const stats: LockerSearchStat[] = [];
|
||||||
|
|
||||||
|
const weapon = asset["Weapon Configuration"];
|
||||||
|
if (weapon?.slotType) {
|
||||||
|
const label =
|
||||||
|
weapon.slotType === "primary"
|
||||||
|
? "Primary"
|
||||||
|
: weapon.slotType === "secondary"
|
||||||
|
? "Secondary"
|
||||||
|
: "Launcher";
|
||||||
|
stats.push({ label: "Slot", value: label });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mag = asset["Magazine Configuration"];
|
||||||
|
if (mag?.roundCapacity != null) stats.push({ label: "Capacity", value: `${mag.roundCapacity}` });
|
||||||
|
if (mag?.ammoType) stats.push({ label: "Ammo", value: mag.ammoType });
|
||||||
|
|
||||||
|
const container = asset["Container Configuration"];
|
||||||
|
if (container?.capacityKg != null) stats.push({ label: "Capacity", value: `${container.capacityKg}kg` });
|
||||||
|
if (container?.weightKg != null) stats.push({ label: "Weight", value: `${container.weightKg}kg` });
|
||||||
|
|
||||||
|
const nvg = asset["Nightvision Configuration"];
|
||||||
|
if (nvg?.nvgGeneration) stats.push({ label: "Gen", value: NVG_GEN_LABELS[nvg.nvgGeneration] ?? nvg.nvgGeneration });
|
||||||
|
if (nvg?.nvgHasThermals) stats.push({ label: "Thermals", value: "Yes" });
|
||||||
|
|
||||||
|
const skin = asset["Skin Configuration"];
|
||||||
|
if (skin?.appliesToAssetTypes?.length) {
|
||||||
|
stats.push({
|
||||||
|
label: "Applies to",
|
||||||
|
value: skin.appliesToAssetTypes.map((t) => getAssetTypeLabel(t)).join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const market = asset.marketTrading;
|
||||||
|
if (market?.Rarity) stats.push({ label: "Rarity", value: RARITY_LABELS[market.Rarity] ?? market.Rarity });
|
||||||
|
|
||||||
|
const dims = asset.storageDimensions;
|
||||||
|
if (dims?.gridWidth != null && dims?.gridHeight != null) {
|
||||||
|
stats.push({ label: "Size", value: `${dims.gridWidth}×${dims.gridHeight}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchEntries(
|
||||||
|
items: LockerGridItem[],
|
||||||
|
loadouts: Loadout[],
|
||||||
|
): LockerSearchEntry[] {
|
||||||
|
const entries = new Map<number, LockerSearchEntry>();
|
||||||
|
|
||||||
|
const upsert = (
|
||||||
|
asset: Asset,
|
||||||
|
quantity: number,
|
||||||
|
location: LockerSearchLocation,
|
||||||
|
displayName?: string,
|
||||||
|
) => {
|
||||||
|
const existing = entries.get(asset.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity;
|
||||||
|
if (!existing.locations.some((l) => l.text === location.text)) {
|
||||||
|
existing.locations.push(location);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.set(asset.id, {
|
||||||
|
key: String(asset.id),
|
||||||
|
assetId: asset.id,
|
||||||
|
asset,
|
||||||
|
assetType: asset.assetType,
|
||||||
|
displayName: displayName?.trim() || asset.name,
|
||||||
|
quantity,
|
||||||
|
stats: buildAssetStats(asset),
|
||||||
|
locations: [location],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.asset) continue;
|
||||||
|
const displayName = item.equipment?.customName?.trim() || item.asset.name;
|
||||||
|
upsert(item.asset, item.quantity, { kind: "locker", text: "In locker" }, displayName);
|
||||||
|
|
||||||
|
for (const attachment of item.equipment?.attachments ?? []) {
|
||||||
|
const asset = resolveAssetRef(attachment.asset, items);
|
||||||
|
if (asset) {
|
||||||
|
upsert(asset, 0, {
|
||||||
|
kind: "attached",
|
||||||
|
text: `Attached to ${displayName}`,
|
||||||
|
parentEntryId: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skin = resolveAssetRef(item.equipment?.skin, items);
|
||||||
|
if (skin) {
|
||||||
|
upsert(skin, 0, {
|
||||||
|
kind: "skin",
|
||||||
|
text: `Applied to ${displayName}`,
|
||||||
|
parentEntryId: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const loadout of loadouts) {
|
||||||
|
const slots = (loadout.slots ?? {}) as LoadoutSlots;
|
||||||
|
for (const layout of WARDROBE_SLOT_LAYOUT) {
|
||||||
|
const value = slots[layout.name];
|
||||||
|
if (value == null) continue;
|
||||||
|
const asset = resolveAssetRef(value, items);
|
||||||
|
if (!asset) continue;
|
||||||
|
const slotLabel = getLoadoutSlot(layout.name)?.label ?? layout.label;
|
||||||
|
upsert(asset, 0, {
|
||||||
|
kind: "loadout",
|
||||||
|
text: `Equipped in loadout "${loadout.name}" · ${slotLabel}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...entries.values()].sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue