Basic scaffold for managing equiped items
This commit is contained in:
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
generated
Normal file
61
flake.lock
generated
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()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user