feat: init the structure ui of the app

This commit is contained in:
2026-08-15 10:13:16 +07:00
parent 0aa03f9c06
commit 570512549d
18 changed files with 1156 additions and 200 deletions
+83
View File
@@ -12,6 +12,7 @@
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
@@ -22,6 +23,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"lucide-react": "^1.31.0",
"tailwindcss": "^4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "^8.2.0",
@@ -266,6 +269,36 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -344,6 +377,8 @@
"electron-to-chromium": ["electron-to-chromium@1.5.406", "", {}, "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
@@ -396,6 +431,8 @@
"globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
@@ -410,6 +447,8 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -456,6 +495,10 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="],
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
@@ -512,6 +555,10 @@
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
@@ -562,10 +609,46 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
}
}
+3
View File
@@ -19,6 +19,7 @@
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
@@ -29,6 +30,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"lucide-react": "^1.31.0",
"tailwindcss": "^4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "^8.2.0",
+106 -112
View File
@@ -1,121 +1,115 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { useState, useMemo } from 'react'
import type { ToolId, ConnectionConfig } from './types'
import { mockModels } from './data/mockModels'
import { tools, toolList } from './data/tools'
import { Header } from './components/Header'
import { ConnectionForm } from './components/ConnectionForm'
import { ModelList } from './components/ModelList'
import { ToolSelector } from './components/ToolSelector'
import { ConfigPreview } from './components/ConfigPreview'
function App() {
const [count, setCount] = useState(0)
export function App() {
const [connection, setConnection] = useState<ConnectionConfig>({
baseUrl: 'http://localhost:20128/v1',
apiKey: '',
})
// Default select 2 popular models
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([
'cc/claude-sonnet-4.5',
'openai/gpt-4o',
])
const [selectedToolId, setSelectedToolId] = useState<ToolId>('copilot')
const [searchQuery, setSearchQuery] = useState('')
const handleToggleModel = (id: string) => {
setSelectedModelIds((prev) =>
prev.includes(id) ? prev.filter((mId) => mId !== id) : [...prev, id]
)
}
const handleSelectAll = () => {
const q = searchQuery.toLowerCase().trim()
const visibleIds = mockModels
.filter(
(m) =>
!q ||
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q) ||
(m.provider && m.provider.toLowerCase().includes(q)) ||
(m.description && m.description.toLowerCase().includes(q))
)
.map((m) => m.id)
setSelectedModelIds((prev) => Array.from(new Set([...prev, ...visibleIds])))
}
const handleDeselectAll = () => {
const q = searchQuery.toLowerCase().trim()
const visibleIds = new Set(
mockModels
.filter(
(m) =>
!q ||
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q) ||
(m.provider && m.provider.toLowerCase().includes(q)) ||
(m.description && m.description.toLowerCase().includes(q))
)
.map((m) => m.id)
)
setSelectedModelIds((prev) => prev.filter((id) => !visibleIds.has(id)))
}
const selectedModels = useMemo(() => {
return mockModels.filter((m) => selectedModelIds.includes(m.id))
}, [selectedModelIds])
const activeTool = tools[selectedToolId] || tools.copilot
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="min-h-screen flex flex-col bg-[var(--app-bg)] text-[var(--app-fg)]">
<Header />
<div className="ticks"></div>
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,540px)] gap-6 items-start">
{/* Left Controls Pane */}
<div className="flex flex-col gap-6 min-w-0">
<ConnectionForm
connection={connection}
onChange={setConnection}
/>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<ModelList
models={mockModels}
selectedIds={selectedModelIds}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onToggleModel={handleToggleModel}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
/>
<div className="ticks"></div>
<section id="spacer"></section>
</>
<ToolSelector
tools={toolList}
selectedTool={selectedToolId}
onSelectTool={setSelectedToolId}
/>
</div>
{/* Right Sticky Preview Pane */}
<div>
<ConfigPreview
tool={activeTool}
connection={connection}
selectedModels={selectedModels}
/>
</div>
</div>
</main>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import React, { useState } from 'react'
import type { ToolMeta, ConnectionConfig, Model } from '../types'
import { Card } from './ui/Card'
import { Button } from './ui/Button'
import { Badge } from './ui/Badge'
import { CodeBlock } from './ui/CodeBlock'
import { Download, Copy, Check, FileCode, Info } from 'lucide-react'
interface ConfigPreviewProps {
tool: ToolMeta
connection: ConnectionConfig
selectedModels: Model[]
}
export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
tool,
connection,
selectedModels,
}) => {
const [copied, setCopied] = useState(false)
const configContent = tool.sampleTemplate(connection, selectedModels)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(configContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// ignore
}
}
const handleDownload = () => {
const blob = new Blob([configContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = tool.targetFilename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
return (
<div className="lg:sticky lg:top-20 flex flex-col gap-4 min-w-0 w-full">
<Card className="flex flex-col gap-3.5 shadow-md min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2.5 border-b border-zinc-200 dark:border-[#22232c] pb-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-7 h-7 shrink-0 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<FileCode className="w-4 h-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs sm:text-sm font-bold text-zinc-900 dark:text-zinc-100 truncate">
{tool.targetFilename}
</span>
<Badge variant="brand" size="sm">
{tool.language.toUpperCase()}
</Badge>
</div>
<span className="text-xs text-zinc-500 dark:text-zinc-400 block text-left truncate">
Target for {tool.name}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-auto">
<Button
variant="outline"
size="sm"
onClick={handleCopy}
icon={copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
>
{copied ? 'Copied' : 'Copy'}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleDownload}
icon={<Download className="w-3.5 h-3.5" />}
>
Download
</Button>
</div>
</div>
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-zinc-100/60 dark:bg-zinc-800/40 text-xs text-zinc-600 dark:text-zinc-400 text-left">
<Info className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<span className="font-semibold text-zinc-800 dark:text-zinc-200">
Save Location:{' '}
</span>
<span>{tool.locationDescription}</span>
</div>
</div>
<CodeBlock
code={configContent}
language={tool.language}
className="max-h-[500px]"
/>
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400 pt-1">
<span>{selectedModels.length} model(s) included</span>
<span>Live preview interpolated</span>
</div>
</Card>
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import React, { useState } from 'react'
import type { ConnectionConfig } from '../types'
import { Input } from './ui/Input'
import { Button } from './ui/Button'
import { Card } from './ui/Card'
import { Badge } from './ui/Badge'
import { Link2, Key, RefreshCw, Eye, EyeOff } from 'lucide-react'
interface ConnectionFormProps {
connection: ConnectionConfig
onChange: (connection: ConnectionConfig) => void
}
export const ConnectionForm: React.FC<ConnectionFormProps> = ({
connection,
onChange,
}) => {
const [showKey, setShowKey] = useState(false)
return (
<Card className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 m-0">
1. Gateway Connection
</h2>
<Badge variant="brand" size="sm">
Local Router
</Badge>
</div>
<span className="text-xs text-zinc-500 dark:text-zinc-400">OpenAI compatible</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
<Input
label="Base URL"
placeholder="http://localhost:20128/v1"
value={connection.baseUrl}
onChange={(e) => onChange({ ...connection, baseUrl: e.target.value })}
leftIcon={<Link2 className="w-4 h-4" />}
helperText="Default 9router endpoint is http://localhost:20128/v1"
/>
<Input
label="API Key (Optional)"
type={showKey ? 'text' : 'password'}
placeholder="sk-..."
value={connection.apiKey}
onChange={(e) => onChange({ ...connection, apiKey: e.target.value })}
leftIcon={<Key className="w-4 h-4" />}
helperText="Passed as authorization header if required"
rightElement={
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors p-1"
title={showKey ? 'Hide key' : 'Show key'}
>
{showKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
}
/>
</div>
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span>Local mock catalog loaded</span>
</div>
<Button
variant="outline"
size="sm"
disabled
icon={<RefreshCw className="w-3.5 h-3.5" />}
title="Live endpoint fetching will be available in next release"
>
Fetch Models (Live)
</Button>
</div>
</Card>
)
}
+50
View File
@@ -0,0 +1,50 @@
import React from 'react'
import { Sparkles, Terminal, Globe } from 'lucide-react'
export const Header: React.FC = () => {
return (
<header className="border-b border-zinc-200 dark:border-[#22232c] bg-white/70 dark:bg-[#121319]/70 backdrop-blur-md sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center text-white shadow-sm shadow-amber-500/25">
<Sparkles className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-base font-bold tracking-tight text-zinc-900 dark:text-white m-0 font-sans">
9router Config Generator
</h1>
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
v1.0
</span>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 m-0">
Generate ready-to-paste AI coding tool configs for your local gateway
</p>
</div>
</div>
<div className="flex items-center gap-2">
<a
href="https://github.com"
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors flex items-center gap-1.5"
>
<Terminal className="w-3.5 h-3.5" />
<span>GitHub</span>
</a>
<a
href="http://localhost:20128"
target="_blank"
rel="noreferrer"
className="hidden sm:flex text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors items-center gap-1.5"
>
<Globe className="w-3.5 h-3.5 text-amber-500" />
<span>9router Local</span>
</a>
</div>
</div>
</header>
)
}
+154
View File
@@ -0,0 +1,154 @@
import React, { useMemo } from 'react'
import type { Model } from '../types'
import { Card } from './ui/Card'
import { Badge } from './ui/Badge'
import { Input } from './ui/Input'
import { Search, CheckSquare, Square, Eye, Wrench, Layers } from 'lucide-react'
interface ModelListProps {
models: Model[]
selectedIds: string[]
searchQuery: string
onSearchChange: (q: string) => void
onToggleModel: (id: string) => void
onSelectAll: () => void
onDeselectAll: () => void
}
export const ModelList: React.FC<ModelListProps> = ({
models,
selectedIds,
searchQuery,
onSearchChange,
onToggleModel,
onSelectAll,
onDeselectAll,
}) => {
const filteredModels = useMemo(() => {
const q = searchQuery.toLowerCase().trim()
if (!q) return models
return models.filter(
(m) =>
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q) ||
(m.provider && m.provider.toLowerCase().includes(q)) ||
(m.description && m.description.toLowerCase().includes(q))
)
}, [models, searchQuery])
const allFilteredSelected =
filteredModels.length > 0 &&
filteredModels.every((m) => selectedIds.includes(m.id))
return (
<Card className="flex flex-col gap-3.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 m-0">
2. Select Models
</h2>
<Badge variant="brand" size="sm">
{selectedIds.length} selected
</Badge>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={allFilteredSelected ? onDeselectAll : onSelectAll}
className="text-xs font-medium text-amber-600 hover:text-amber-700 dark:text-amber-400 transition-colors"
>
{allFilteredSelected ? 'Deselect visible' : 'Select all visible'}
</button>
</div>
</div>
<Input
placeholder="Filter models by id, provider, name..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-4 h-4" />}
className="text-xs"
/>
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-2">
{filteredModels.length === 0 ? (
<div className="p-6 text-center text-xs text-zinc-500 dark:text-zinc-400 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-lg">
No models matching "{searchQuery}"
</div>
) : (
filteredModels.map((model) => {
const isSelected = selectedIds.includes(model.id)
return (
<div
key={model.id}
onClick={() => onToggleModel(model.id)}
className={`flex items-start gap-3 p-2.5 rounded-lg border transition-all cursor-pointer select-none text-left ${
isSelected
? 'bg-amber-500/5 dark:bg-amber-500/10 border-amber-500/40 dark:border-amber-500/30'
: 'bg-zinc-50/50 dark:bg-[#121319]/50 border-zinc-200/80 dark:border-zinc-800/80 hover:bg-zinc-100 dark:hover:bg-zinc-800/60'
}`}
>
<button
type="button"
aria-label={isSelected ? `Deselect ${model.name}` : `Select ${model.name}`}
className="mt-0.5 text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300"
>
{isSelected ? (
<CheckSquare className="w-4 h-4 text-amber-500" />
) : (
<Square className="w-4 h-4" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5 mb-0.5">
<span className="font-mono text-xs font-semibold text-zinc-900 dark:text-zinc-100">
{model.id}
</span>
{model.provider && (
<span className="text-[10px] px-1.5 py-0.2 rounded bg-zinc-200/70 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 font-medium">
{model.provider}
</span>
)}
</div>
{model.name && model.name !== model.id && (
<p className="text-xs text-zinc-700 dark:text-zinc-300 truncate">
{model.name}
</p>
)}
{model.description && (
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5 line-clamp-1">
{model.description}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0 text-[10px] text-zinc-400">
{model.contextWindow && (
<span className="flex items-center gap-0.5 font-mono" title="Context window">
<Layers className="w-3 h-3 text-zinc-400" />
{(model.contextWindow / 1000).toFixed(0)}k
</span>
)}
<div className="flex items-center gap-1">
{model.vision && (
<span title="Supports Vision" className="flex items-center">
<Eye className="w-3 h-3 text-zinc-400" />
</span>
)}
{model.toolCalling && (
<span title="Supports Function Calling" className="flex items-center">
<Wrench className="w-3 h-3 text-zinc-400" />
</span>
)}
</div>
</div>
</div>
)
})
)}
</div>
</Card>
)
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react'
import type { ToolId, ToolMeta } from '../types'
import { Card } from './ui/Card'
import { Badge } from './ui/Badge'
import { Bot, Terminal, Cpu, Check } from 'lucide-react'
interface ToolSelectorProps {
tools: ToolMeta[]
selectedTool: ToolId
onSelectTool: (id: ToolId) => void
}
export const ToolSelector: React.FC<ToolSelectorProps> = ({
tools,
selectedTool,
onSelectTool,
}) => {
const getIcon = (iconName: ToolMeta['iconName']) => {
switch (iconName) {
case 'bot':
return <Bot className="w-5 h-5" />
case 'terminal':
return <Terminal className="w-5 h-5" />
case 'cpu':
return <Cpu className="w-5 h-5" />
}
}
return (
<Card className="flex flex-col gap-3.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 m-0">
3. Choose Target Tool
</h2>
<Badge variant="outline" size="sm">
{tools.length} available
</Badge>
</div>
<span className="text-xs text-zinc-500 dark:text-zinc-400">Single select</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{tools.map((tool) => {
const isSelected = selectedTool === tool.id
return (
<div
key={tool.id}
onClick={() => onSelectTool(tool.id)}
className={`relative flex flex-col justify-between p-3.5 rounded-xl border transition-all cursor-pointer text-left ${
isSelected
? 'bg-amber-500/5 dark:bg-amber-500/10 border-amber-500 shadow-sm shadow-amber-500/10 ring-1 ring-amber-500'
: 'bg-zinc-50/50 dark:bg-[#121319]/50 border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800/60'
}`}
>
{isSelected && (
<div className="absolute top-2.5 right-2.5 w-4 h-4 rounded-full bg-amber-500 text-white flex items-center justify-center">
<Check className="w-2.5 h-2.5 stroke-[3]" />
</div>
)}
<div>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center mb-2.5 ${
isSelected
? 'bg-amber-500 text-white'
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300'
}`}
>
{getIcon(tool.iconName)}
</div>
<h3 className="text-sm font-bold text-zinc-900 dark:text-zinc-100 mb-0.5">
{tool.name}
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-2 leading-snug">
{tool.tagline}
</p>
</div>
<div className="pt-2 border-t border-zinc-200/60 dark:border-zinc-800/60">
<span className="font-mono text-[11px] text-zinc-600 dark:text-zinc-400 truncate block">
{tool.targetFilename}
</span>
</div>
</div>
)
})}
</div>
</Card>
)
}
+39
View File
@@ -0,0 +1,39 @@
import React from 'react'
interface BadgeProps {
children: React.ReactNode
variant?: 'default' | 'brand' | 'outline' | 'success'
size?: 'sm' | 'md'
className?: string
}
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'sm',
className = '',
}) => {
const sizeClasses = {
sm: 'px-2 py-0.5 text-[11px]',
md: 'px-2.5 py-1 text-xs',
}[size]
const variantClasses = {
default:
'bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 font-medium',
brand:
'bg-amber-500/10 text-amber-600 dark:text-amber-400 font-semibold border border-amber-500/20',
outline:
'border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 font-medium',
success:
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium border border-emerald-500/20',
}[variant]
return (
<span
className={`inline-flex items-center justify-center rounded-md whitespace-nowrap leading-none transition-colors ${sizeClasses} ${variantClasses} ${className}`}
>
{children}
</span>
)
}
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
icon?: React.ReactNode
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
icon,
className = '',
disabled,
...props
}) => {
const baseClasses =
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed select-none'
const sizeClasses = {
sm: 'px-2.5 py-1 text-xs gap-1.5',
md: 'px-3.5 py-2 text-sm gap-2',
lg: 'px-4.5 py-2.5 text-base gap-2.5',
}[size]
const variantClasses = {
primary:
'bg-amber-500 hover:bg-amber-600 text-white shadow-sm shadow-amber-500/20 active:translate-y-px',
secondary:
'bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-zinc-800 dark:text-zinc-200 border border-zinc-200 dark:border-zinc-700',
outline:
'bg-transparent hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border border-zinc-300 dark:border-zinc-700',
ghost:
'bg-transparent hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-600 dark:text-zinc-400',
}[variant]
return (
<button
className={`${baseClasses} ${sizeClasses} ${variantClasses} ${className}`}
disabled={disabled}
{...props}
>
{icon && <span className="inline-flex shrink-0">{icon}</span>}
{children}
</button>
)
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react'
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
selected?: boolean
interactive?: boolean
}
export const Card: React.FC<CardProps> = ({
children,
selected = false,
interactive = false,
className = '',
...props
}) => {
return (
<div
className={`rounded-xl border transition-all duration-150 p-4 ${
selected
? 'bg-white dark:bg-[#161720] border-amber-500 dark:border-amber-500 shadow-sm shadow-amber-500/10 ring-1 ring-amber-500'
: 'bg-white dark:bg-[#14151c] border-zinc-200 dark:border-[#262732]'
} ${
interactive
? 'cursor-pointer hover:border-zinc-300 dark:hover:border-zinc-700 hover:shadow-xs'
: ''
} ${className}`}
{...props}
>
{children}
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React, { useState } from 'react'
import { Copy, Check } from 'lucide-react'
interface CodeBlockProps {
code: string
language?: string
className?: string
}
export const CodeBlock: React.FC<CodeBlockProps> = ({
code,
language,
className = '',
}) => {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// fallback if clipboard api fails
}
}
return (
<div className={`relative group rounded-lg overflow-hidden border border-zinc-200 dark:border-[#262732] bg-[#f8f8fa] dark:bg-[#0c0d12] ${className}`}>
<div className="flex items-center justify-between px-3.5 py-2 border-b border-zinc-200 dark:border-[#22232c] bg-zinc-100/70 dark:bg-[#12131a]/80 text-xs text-zinc-500 font-mono">
<span>{language ? language.toUpperCase() : 'CODE'}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors p-1 rounded hover:bg-zinc-200/50 dark:hover:bg-zinc-800"
title="Copy snippet"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-medium">Copied!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</>
)}
</button>
</div>
<div className="overflow-x-auto p-3.5 font-mono text-[13px] leading-relaxed text-zinc-800 dark:text-zinc-200 selection:bg-amber-500/20">
<pre className="m-0 tab-4 whitespace-pre">
<code>{code}</code>
</pre>
</div>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
helperText?: string
leftIcon?: React.ReactNode
rightElement?: React.ReactNode
}
export const Input: React.FC<InputProps> = ({
label,
helperText,
leftIcon,
rightElement,
className = '',
id,
...props
}) => {
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
return (
<div className="flex flex-col gap-1.5 w-full text-left">
{label && (
<label
htmlFor={inputId}
className="text-xs font-semibold uppercase tracking-wider text-zinc-600 dark:text-zinc-400"
>
{label}
</label>
)}
<div className="relative flex items-center w-full">
{leftIcon && (
<div className="absolute left-3 flex items-center pointer-events-none text-zinc-400 dark:text-zinc-500">
{leftIcon}
</div>
)}
<input
id={inputId}
className={`w-full bg-white dark:bg-[#12131a] text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 border border-zinc-200 dark:border-zinc-800 rounded-lg px-3.5 py-2 text-sm transition-all focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 ${
leftIcon ? 'pl-9' : ''
} ${rightElement ? 'pr-10' : ''} ${className}`}
{...props}
/>
{rightElement && (
<div className="absolute right-2.5 flex items-center">
{rightElement}
</div>
)}
</div>
{helperText && (
<span className="text-xs text-zinc-500 dark:text-zinc-400">{helperText}</span>
)}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import type { Model } from '../types'
export const mockModels: Model[] = [
{
id: 'cc/claude-opus-4-7',
name: 'Claude Opus 4.7 (Anthropic via 9router)',
family: 'claude-opus-4-7',
provider: 'Anthropic',
contextWindow: 200000,
vision: true,
toolCalling: true,
description: 'Most powerful reasoning model for complex architecture and codebases',
},
{
id: 'cc/claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
family: 'claude-sonnet-4-5',
provider: 'Anthropic',
contextWindow: 200000,
vision: true,
toolCalling: true,
description: 'High-speed intelligent coding powerhouse with strong agentic skills',
},
{
id: 'kr/claude-sonnet-4.5',
name: 'Claude Sonnet 4.5 (KR Route)',
family: 'claude-sonnet-4-5',
provider: 'Anthropic',
contextWindow: 200000,
vision: true,
toolCalling: true,
description: 'Alternative regional routing for low latency',
},
{
id: 'openai/gpt-4o',
name: 'GPT-4o',
family: 'gpt-4o',
provider: 'OpenAI',
contextWindow: 128000,
vision: true,
toolCalling: true,
description: 'Flagship multimodal omni model with rapid reasoning and code output',
},
{
id: 'openai/o3-mini',
name: 'o3-mini (High)',
family: 'o3-mini',
provider: 'OpenAI',
contextWindow: 200000,
vision: false,
toolCalling: true,
description: 'Deep mathematical and algorithmic reasoning specialist',
},
{
id: 'vertex/gemini-3-flash-preview',
name: 'Gemini 3 Flash Preview',
family: 'gemini-3-flash',
provider: 'Google Vertex',
contextWindow: 1000000,
vision: true,
toolCalling: true,
description: 'Ultra high-speed large 1M token context model for massive file ingestion',
},
{
id: 'deepseek/deepseek-r1',
name: 'DeepSeek R1',
family: 'deepseek-r1',
provider: 'DeepSeek',
contextWindow: 64000,
vision: false,
toolCalling: true,
description: 'Open-weights reasoning model with high performance on coding tasks',
},
]
+93
View File
@@ -0,0 +1,93 @@
import type { ToolId, ToolMeta, ConnectionConfig, Model } from '../types'
export const tools: Record<ToolId, ToolMeta> = {
copilot: {
id: 'copilot',
name: 'GitHub Copilot',
tagline: 'VS Code chatLanguageModels.json configuration',
targetFilename: 'chatLanguageModels.json',
locationDescription: 'VS Code User settings or global profile directory',
language: 'json',
iconName: 'bot',
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => {
const activeModels = selectedModels.length > 0 ? selectedModels : [
{
id: 'cc/claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
family: 'claude-sonnet-4-5',
contextWindow: 200000,
vision: true,
toolCalling: true,
}
]
const entries = activeModels.map((m) => ({
id: `9router-${m.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
name: `${m.name || m.id} (9router)`,
vendor: 'customoai',
family: m.family || m.id.split('/').pop() || 'custom',
version: '1.0.0',
maxInputTokens: m.contextWindow || 128000,
maxOutputTokens: 8192,
endpoint: connection.baseUrl.replace(/\/+$/, '') + '/chat/completions',
apiKey: connection.apiKey ? connection.apiKey : '${input:9router-api-key}',
model: m.id,
toolCalling: m.toolCalling ?? true,
vision: m.vision ?? true,
}))
return JSON.stringify(entries, null, 2)
},
},
'claude-code': {
id: 'claude-code',
name: 'Claude Code',
tagline: 'Anthropic CLI ~/.claude/config.json',
targetFilename: 'config.json',
locationDescription: '~/.claude/config.json or environment variables',
language: 'json',
iconName: 'terminal',
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => {
const primaryModel = selectedModels[0]?.id || 'cc/claude-sonnet-4.5'
const configObj = {
anthropic_api_base: connection.baseUrl.replace(/\/+$/, ''),
anthropic_api_key: connection.apiKey || 'sk-9router-local-key',
model: primaryModel,
verbose: false,
}
const envNotice = `// Target: ~/.claude/config.json\n// Alternative environment variables:\n// export ANTHROPIC_BASE_URL="${connection.baseUrl}"\n// export ANTHROPIC_AUTH_TOKEN="${connection.apiKey || 'sk-9router-local-key'}"\n// export ANTHROPIC_MODEL="${primaryModel}"\n\n`
return envNotice + JSON.stringify(configObj, null, 2)
},
},
codex: {
id: 'codex',
name: 'Codex CLI',
tagline: 'Codex CLI ~/.codex/config.toml configuration',
targetFilename: 'config.toml',
locationDescription: '~/.codex/config.toml',
language: 'toml',
iconName: 'cpu',
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => {
const primaryModel = selectedModels[0]?.id || 'openai/gpt-4o'
const apiKeyVal = connection.apiKey || '9ROUTER_API_KEY'
return `# ~/.codex/config.toml
model_provider = "9router"
model = "${primaryModel}"
temperature = 0.2
[model_providers.9router]
name = "9router Local Gateway"
base_url = "${connection.baseUrl.replace(/\/+$/, '')}"
env_key = "${apiKeyVal}"
wire_format = "openai"
`
},
},
}
export const toolList: ToolMeta[] = [
tools.copilot,
tools['claude-code'],
tools.codex,
]
+49 -88
View File
@@ -1,111 +1,72 @@
@import "tailwindcss";
@theme {
--color-brand: #f59e0b;
--color-brand-hover: #d97706;
--color-brand-subtle: rgba(245, 158, 11, 0.1);
--color-brand-border: rgba(245, 158, 11, 0.35);
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--app-bg: #fcfcfd;
--app-fg: #09090b;
--app-muted: #71717a;
--app-card-bg: #ffffff;
--app-card-border: #e4e4e7;
--app-code-bg: #f4f4f5;
--app-code-border: #e4e4e7;
--app-hover-bg: #f4f4f5;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
--app-bg: #0d0e12;
--app-fg: #f4f4f5;
--app-muted: #9ca3af;
--app-card-bg: #15161d;
--app-card-border: #262732;
--app-code-bg: #101117;
--app-code-border: #22232c;
--app-hover-bg: #1c1e28;
}
}
#social .button-icon {
filter: invert(1) brightness(2);
}
html, body {
margin: 0;
padding: 0;
background-color: var(--app-bg);
color: var(--app-fg);
font-family: var(--font-sans);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
min-height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
::-webkit-scrollbar-track {
background: transparent;
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
::-webkit-scrollbar-thumb {
background: rgba(156, 163, 175, 0.3);
border-radius: 9999px;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.5);
}
+28
View File
@@ -0,0 +1,28 @@
export type ToolId = 'copilot' | 'claude-code' | 'codex'
export interface Model {
id: string
name: string
contextWindow?: number
provider?: string
description?: string
family?: string
vision?: boolean
toolCalling?: boolean
}
export interface ConnectionConfig {
baseUrl: string
apiKey: string
}
export interface ToolMeta {
id: ToolId
name: string
tagline: string
targetFilename: string
locationDescription: string
language: 'json' | 'toml' | 'bash'
iconName: 'bot' | 'terminal' | 'cpu'
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => string
}
+2
View File
@@ -1,10 +1,12 @@
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react(),
babel({ presets: [reactCompilerPreset()] })
],