Basic scaffold for managing equiped items
This commit is contained in:
commit
493596e505
19
.direnv/bin/nix-direnv-reload
Executable file
19
.direnv/bin/nix-direnv-reload
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
if [[ ! -d "/home/drew/Documents/src/wow-gear-finder" ]]; then
|
||||
echo "Cannot find source directory; Did you move it?"
|
||||
echo "(Looking for "/home/drew/Documents/src/wow-gear-finder")"
|
||||
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# rebuild the cache forcefully
|
||||
_nix_direnv_force_reload=1 direnv exec "/home/drew/Documents/src/wow-gear-finder" true
|
||||
|
||||
# Update the mtime for .envrc.
|
||||
# This will cause direnv to reload again - but without re-building.
|
||||
touch "/home/drew/Documents/src/wow-gear-finder/.envrc"
|
||||
|
||||
# Also update the timestamp of whatever profile_rc we have.
|
||||
# This makes sure that we know we are up to date.
|
||||
touch -r "/home/drew/Documents/src/wow-gear-finder/.envrc" "/home/drew/Documents/src/wow-gear-finder/.direnv"/*.rc
|
||||
1
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
Symbolic link
1
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
Symbolic link
@ -0,0 +1 @@
|
||||
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
|
||||
1
.direnv/flake-inputs/cfzjjbkm6p6whq10ha83dqv6j0ipp8fn-source
Symbolic link
1
.direnv/flake-inputs/cfzjjbkm6p6whq10ha83dqv6j0ipp8fn-source
Symbolic link
@ -0,0 +1 @@
|
||||
/nix/store/cfzjjbkm6p6whq10ha83dqv6j0ipp8fn-source
|
||||
1
.direnv/flake-inputs/fjwfziqknkgff7xbax6j03xkhh9l2dnh-source
Symbolic link
1
.direnv/flake-inputs/fjwfziqknkgff7xbax6j03xkhh9l2dnh-source
Symbolic link
@ -0,0 +1 @@
|
||||
/nix/store/fjwfziqknkgff7xbax6j03xkhh9l2dnh-source
|
||||
1
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
Symbolic link
1
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
Symbolic link
@ -0,0 +1 @@
|
||||
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
|
||||
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
@ -0,0 +1 @@
|
||||
/nix/store/v2f7pcs3h012wwab34pjyq393xncrvhb-nix-shell-env
|
||||
2096
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
2096
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
File diff suppressed because it is too large
Load Diff
5
.envrc
Normal file
5
.envrc
Normal file
@ -0,0 +1,5 @@
|
||||
use flake
|
||||
|
||||
# Add Node modules only to the end of the path.
|
||||
# `layout node` will add it to the front, which is not as secure
|
||||
export PATH=$PATH:$(pwd)/node_modules/.bin
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
69
README.md
Normal file
69
README.md
Normal file
@ -0,0 +1,69 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
61
flake.lock
Normal file
61
flake.lock
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1755078291,
|
||||
"narHash": "sha256-Hu/gTDoi4uy6TAKISPHQusSMy8U6xUbLSDjKBYdhDIY=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3385ca0cd7e14c1a1eb80401fe011705ff012323",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
134
flake.nix
Normal file
134
flake.nix
Normal file
@ -0,0 +1,134 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-25.05";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
utils,
|
||||
}:
|
||||
utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
# The path to the npm project
|
||||
src = ./.;
|
||||
|
||||
# Read the package-lock.json as a Nix attrset
|
||||
packageLock = builtins.fromJSON (builtins.readFile (src + "/package-lock.json"));
|
||||
|
||||
# Create an array of all (meaningful) dependencies
|
||||
deps = builtins.attrValues (removeAttrs packageLock.packages [ "" ]);
|
||||
# ++ builtins.attrValues (removeAttrs packageLock.dependencies [ "" ]);
|
||||
|
||||
# Turn each dependency into a fetchurl call
|
||||
tarballs = map (
|
||||
p:
|
||||
pkgs.fetchurl {
|
||||
url = p.resolved;
|
||||
hash = p.integrity;
|
||||
}
|
||||
) deps;
|
||||
|
||||
# Write a file with the list of tarballs
|
||||
tarballsFile = pkgs.writeTextFile {
|
||||
name = "tarballs";
|
||||
text = builtins.concatStringsSep "\n" tarballs;
|
||||
};
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
devShell =
|
||||
with pkgs;
|
||||
mkShell {
|
||||
buildInputs = [
|
||||
nodejs_22 # Even versions are more stable
|
||||
pocketbase
|
||||
];
|
||||
};
|
||||
|
||||
nodeModules = pkgs.stdenv.mkDerivation {
|
||||
name = "wow-gear-finder-node-modules";
|
||||
src = ./.;
|
||||
buildInputs = [ pkgs.nodejs ];
|
||||
buildPhase = ''
|
||||
export HOME=$PWD/.home
|
||||
export npm_config_cache=$PWD/.npm
|
||||
mkdir -p $out
|
||||
cd $out
|
||||
cp -r $src/package.json $src/package-lock.json .
|
||||
|
||||
while read package
|
||||
do
|
||||
echo "caching $package"
|
||||
npm cache add "$package"
|
||||
done <${tarballsFile}
|
||||
|
||||
npm ci
|
||||
'';
|
||||
};
|
||||
|
||||
# Derivation for node_modules (npm ci)
|
||||
# nodeModules = pkgs.stdenv.mkDerivation {
|
||||
# name = "wow-gear-finder-node-modules";
|
||||
# src = ./.;
|
||||
# buildInputs = [ pkgs.nodejs_22 ];
|
||||
# installPhase = ''
|
||||
# mkdir -p $out
|
||||
# cp package.json package-lock.json $out/
|
||||
# cd $out
|
||||
# npm ci --ignore-scripts
|
||||
# '';
|
||||
# # Only output node_modules
|
||||
# dontBuild = true;
|
||||
# dontConfigure = true;
|
||||
# };
|
||||
|
||||
# Derivation for vite build
|
||||
viteBuild = pkgs.stdenv.mkDerivation {
|
||||
name = "wow-gear-finder-vite-build";
|
||||
src = ./.;
|
||||
buildInputs = [ pkgs.nodejs_22 ];
|
||||
# Use node_modules from previous derivation
|
||||
NODE_PATH = "${self.outputs.nodeModules}/node_modules";
|
||||
installPhase = ''
|
||||
cp -r $src $out/app
|
||||
cd $out/app
|
||||
cp ${self.outputs.nodeModules}/package.json .
|
||||
cp -r ${self.outputs.nodeModules}/node_modules .
|
||||
npm run build
|
||||
mkdir -p $out/dist
|
||||
cp -r dist/* $out/dist/
|
||||
'';
|
||||
dontBuild = true;
|
||||
dontConfigure = true;
|
||||
};
|
||||
|
||||
dockerImage = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "wow-gear-finder-app";
|
||||
tag = "latest";
|
||||
contents = [ pkgs.caddy ];
|
||||
config = {
|
||||
Cmd = [
|
||||
"/bin/caddy"
|
||||
"file-server"
|
||||
"--root"
|
||||
"/srv"
|
||||
"--listen"
|
||||
":8080"
|
||||
];
|
||||
ExposedPorts = {
|
||||
"8080/tcp" = { };
|
||||
};
|
||||
WorkingDir = "/srv";
|
||||
};
|
||||
extraCommands = ''
|
||||
mkdir -p $out/srv
|
||||
cp -r ${self.outputs.viteBuild}/dist/* $out/srv/
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
23
index.html
Normal file
23
index.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WoW Gear Finder</title>
|
||||
|
||||
<!-- Wowhead Links -->
|
||||
<script>
|
||||
const whTooltips = {
|
||||
colorLinks: true,
|
||||
iconizeLinks: true,
|
||||
renameLinks: true,
|
||||
};
|
||||
</script>
|
||||
<script src="https://wow.zamimg.com/js/tooltips.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3625
package-lock.json
generated
Normal file
3625
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "wow-gear-finder",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
src/App.css
Normal file
42
src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
51
src/App.tsx
Normal file
51
src/App.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useReducer } from "react";
|
||||
import "./App.css";
|
||||
import { Equipment } from "./components/Equipment";
|
||||
import { reducer, withLoadingAction } from "./lib/state";
|
||||
import type { ItemId } from "./lib/types";
|
||||
|
||||
function App() {
|
||||
const [state, dispatch] = useReducer(withLoadingAction(reducer), "loading");
|
||||
|
||||
useEffect(() => {
|
||||
if (state === "loading") {
|
||||
console.log("Loading state...");
|
||||
// TODO: Parse with Zod?
|
||||
const stateValue = localStorage.getItem("state");
|
||||
if (stateValue) {
|
||||
dispatch({
|
||||
action: "loadState",
|
||||
state: JSON.parse(stateValue),
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
action: "loadState",
|
||||
state: {
|
||||
equipedItems: [{ id: 219309 as ItemId, quality: "champion" }],
|
||||
bisList: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("Saving state...");
|
||||
localStorage.setItem("state", JSON.stringify(state));
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
if (state === "loading") {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>WoW Gear Finder</h1>
|
||||
<Equipment
|
||||
state={state}
|
||||
onEquip={(item) => dispatch({ action: "equipItem", item })}
|
||||
onUnequip={(item) => dispatch({ action: "unequipItem", item })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
41
src/components/Equipment.tsx
Normal file
41
src/components/Equipment.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { ItemsById } from "../lib/items";
|
||||
import type { State } from "../lib/state";
|
||||
import { Slots, type Item, type Slot } from "../lib/types";
|
||||
import { ItemLink } from "./ItemLink";
|
||||
import { ItemTypeahead } from "./ItemTypeahead";
|
||||
|
||||
export type Props = {
|
||||
state: State;
|
||||
onEquip: (item: Item) => void;
|
||||
onUnequip: (item: Item) => void;
|
||||
};
|
||||
|
||||
export const Equipment = ({ state, onEquip, onUnequip }: Props) => (
|
||||
<div>
|
||||
<ItemTypeahead value={null} onSelect={onEquip} />
|
||||
{Slots.map((slot) => (
|
||||
<Slot state={state} slot={slot} onUnequip={onUnequip} key={slot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type SlotProps = {
|
||||
state: State;
|
||||
slot: Slot;
|
||||
onUnequip: (item: Item) => void;
|
||||
};
|
||||
|
||||
const Slot = ({ state, slot, onUnequip }: SlotProps) => (
|
||||
<div>
|
||||
{slot}:{" "}
|
||||
{state.equipedItems
|
||||
.map((item) => ({ ...item, item: ItemsById[item.id] }))
|
||||
.filter((item) => item && item.item.slot === slot)
|
||||
.map((item) => (
|
||||
<span>
|
||||
<ItemLink item={item.item} />({item.quality})
|
||||
<button onClick={() => onUnequip(item.item)}>X</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
15
src/components/ItemLink.tsx
Normal file
15
src/components/ItemLink.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { Item } from "../lib/types";
|
||||
|
||||
export type Props = {
|
||||
item: Item;
|
||||
};
|
||||
|
||||
export const ItemLink = ({ item }: Props) => (
|
||||
<a
|
||||
href={`https://www.wowhead.com/item=${item.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
);
|
||||
41
src/components/ItemTypeahead.tsx
Normal file
41
src/components/ItemTypeahead.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { useState } from "react";
|
||||
import { AllItems } from "../lib/items";
|
||||
import type { Item } from "../lib/types";
|
||||
|
||||
export type Props = {
|
||||
value: Item | null;
|
||||
onSelect: (item: Item) => void;
|
||||
};
|
||||
|
||||
export const ItemTypeahead = ({ value, onSelect }: Props) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredItems =
|
||||
query === ""
|
||||
? []
|
||||
: AllItems.filter((item) =>
|
||||
item.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox value={value} onChange={onSelect} onClose={() => setQuery("")}>
|
||||
<ComboboxInput<Item | null>
|
||||
displayValue={(item) => item?.name || ""}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<ComboboxOptions anchor="bottom">
|
||||
{filteredItems.map((item) => (
|
||||
<ComboboxOption key={item.id} value={item}>
|
||||
{item.name}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
68
src/index.css
Normal file
68
src/index.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
32
src/lib/items.ts
Normal file
32
src/lib/items.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import _ from "lodash";
|
||||
import { type Item, type ItemId, type Slot } from "./types";
|
||||
|
||||
export const PrioryOfTheSacredFlame = [
|
||||
{ id: 219309 as ItemId, slot: "trinket", name: "Tome of Light's Devotion" },
|
||||
{ id: 219308 as ItemId, slot: "trinket", name: "Signet of the Priory" },
|
||||
{ id: 252009 as ItemId, slot: "neck", name: "Bloodstained Memento" },
|
||||
{ id: 221200 as ItemId, slot: "finger", name: "Radiant Necromancer's Band" },
|
||||
{ id: 221125 as ItemId, slot: "head", name: "Helm of the Righteous Crusade" },
|
||||
] as const;
|
||||
|
||||
export const AllItems: Item[] = [
|
||||
...PrioryOfTheSacredFlame.map((item) => ({
|
||||
...item,
|
||||
source: "priory" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
export const ItemsById: Record<string, Item> = AllItems.reduce(
|
||||
(db, item) => {
|
||||
db[item.id] = item;
|
||||
return db;
|
||||
},
|
||||
{} as Record<ItemId, Item>,
|
||||
);
|
||||
|
||||
export const ItemsBySlot: Record<Slot, Item[]> = _.groupBy(
|
||||
AllItems,
|
||||
(item) => item.slot,
|
||||
);
|
||||
|
||||
export const itemsForSlot = (slot: Slot): Item[] => ItemsBySlot[slot] ?? [];
|
||||
64
src/lib/state.ts
Normal file
64
src/lib/state.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { EquipedItem, Item, ItemId } from "./types";
|
||||
|
||||
export type State = {
|
||||
equipedItems: EquipedItem[];
|
||||
bisList: ItemId[];
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
action: "equipItem";
|
||||
item: Item;
|
||||
}
|
||||
| {
|
||||
action: "unequipItem";
|
||||
item: Item;
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.action) {
|
||||
case "equipItem":
|
||||
return {
|
||||
...state,
|
||||
equipedItems: [
|
||||
...state.equipedItems,
|
||||
{
|
||||
id: action.item.id,
|
||||
quality: "champion", // Default quality, can be changed later
|
||||
},
|
||||
],
|
||||
};
|
||||
case "unequipItem":
|
||||
return {
|
||||
...state,
|
||||
equipedItems: state.equipedItems.filter(
|
||||
(item) => item.id !== action.item.id,
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export type LoadingAction<S> = {
|
||||
action: "loadState";
|
||||
state: S;
|
||||
};
|
||||
|
||||
function isLoadingAction<A, S>(
|
||||
action: A | LoadingAction<S>,
|
||||
): action is LoadingAction<S> {
|
||||
return (action as LoadingAction<S>).action === "loadState";
|
||||
}
|
||||
|
||||
export const withLoadingAction =
|
||||
<S, A>(reducer: (state: S, action: A) => S) =>
|
||||
(state: S | "loading", action: A | LoadingAction<S>): S | "loading" => {
|
||||
if (state !== "loading" && !isLoadingAction(action)) {
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
if (state === "loading" && isLoadingAction(action)) {
|
||||
return action.state;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
37
src/lib/types.ts
Normal file
37
src/lib/types.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export const Slots = [
|
||||
"head",
|
||||
"neck",
|
||||
"shoulders",
|
||||
"chest",
|
||||
"back",
|
||||
"wrist",
|
||||
"hands",
|
||||
"waist",
|
||||
"legs",
|
||||
"feet",
|
||||
"finger",
|
||||
"trinket",
|
||||
"1h-weapon",
|
||||
"2h-weapon",
|
||||
"shield",
|
||||
] as const;
|
||||
|
||||
export type Slot = (typeof Slots)[number];
|
||||
|
||||
export type Quality = "champion" | "hero" | "myth";
|
||||
|
||||
export type ItemId = number & { __type: "ItemId" };
|
||||
|
||||
export type Source = "priory";
|
||||
|
||||
export type Item = {
|
||||
id: ItemId;
|
||||
slot: Slot;
|
||||
name: string;
|
||||
source: Source;
|
||||
};
|
||||
|
||||
export type EquipedItem = {
|
||||
id: ItemId;
|
||||
quality: Quality;
|
||||
};
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user