forked from syntaxbullet/aurorabot
feat: add admin dashboard with sidebar navigation and stats overview
Replace placeholder panel with a full dashboard landing page showing bot stats, leaderboards, and recent events from /api/stats. Add sidebar navigation with placeholder pages for Users, Items, Classes, Quests, Lootdrops, Moderation, Transactions, and Settings. Update theme to match Aurora design guidelines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
bun.lock
27
bun.lock
@@ -24,9 +24,13 @@
|
|||||||
"name": "panel",
|
"name": "panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.6.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
@@ -34,7 +38,6 @@
|
|||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"daisyui": "^5.0.43",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -309,14 +312,14 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -393,6 +396,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
||||||
|
|
||||||
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
@@ -421,10 +426,6 @@
|
|||||||
|
|
||||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
|
|
||||||
|
|
||||||
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
|
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||||
@@ -433,16 +434,18 @@
|
|||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|||||||
769
docs/aurora-admin-design-guidelines.md
Normal file
769
docs/aurora-admin-design-guidelines.md
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
# Aurora Admin Panel - Design Guidelines
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
The Aurora Admin Panel embodies the intersection of celestial mystique and institutional precision. It is a command center for academy administration—powerful, sophisticated, and unmistakably authoritative. Every interface element should communicate control, clarity, and prestige.
|
||||||
|
|
||||||
|
**Core Principles:**
|
||||||
|
- **Authority over Friendliness**: This is an administrative tool, not a consumer app
|
||||||
|
- **Data Clarity**: Information density balanced with elegant presentation
|
||||||
|
- **Celestial Aesthetic**: Subtle cosmic theming that doesn't compromise functionality
|
||||||
|
- **Institutional Grade**: Professional, trustworthy, built to manage complex systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Foundation
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
**Background Hierarchy**
|
||||||
|
```
|
||||||
|
Level 0 (Base) #0A0A0F Eclipse Void - Deepest background
|
||||||
|
Level 1 (Container) #151520 Midnight Canvas - Cards, panels, modals
|
||||||
|
Level 2 (Surface) #1E1B4B Nebula Surface - Elevated elements
|
||||||
|
Level 3 (Raised) #2D2A5F Stellar Overlay - Hover states, dropdowns
|
||||||
|
```
|
||||||
|
|
||||||
|
**Text Hierarchy**
|
||||||
|
```
|
||||||
|
Primary Text #F9FAFB Starlight White - Headings, key data
|
||||||
|
Secondary Text #E5E7EB Stardust Silver - Body text, labels
|
||||||
|
Tertiary Text #9CA3AF Cosmic Gray - Helper text, timestamps
|
||||||
|
Disabled Text #6B7280 Void Gray - Inactive elements
|
||||||
|
```
|
||||||
|
|
||||||
|
**Brand Accents**
|
||||||
|
```
|
||||||
|
Primary (Action) #8B5CF6 Aurora Purple - Primary buttons, links, active states
|
||||||
|
Secondary (Info) #3B82F6 Nebula Blue - Informational elements
|
||||||
|
Success #10B981 Emerald - Confirmations, positive indicators
|
||||||
|
Warning #F59E0B Amber - Cautions, alerts
|
||||||
|
Danger #DC2626 Crimson - Errors, destructive actions
|
||||||
|
Gold (Prestige) #FCD34D Celestial Gold - Premium features, highlights
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constellation Tier Colors** (for data visualization)
|
||||||
|
```
|
||||||
|
Constellation A #FCD34D Celestial Gold
|
||||||
|
Constellation B #8B5CF6 Aurora Purple
|
||||||
|
Constellation C #3B82F6 Nebula Blue
|
||||||
|
Constellation D #6B7280 Slate Gray
|
||||||
|
```
|
||||||
|
|
||||||
|
**Semantic Colors**
|
||||||
|
```
|
||||||
|
Currency (AU) #FCD34D Gold - Astral Units indicators
|
||||||
|
Currency (CU) #8B5CF6 Purple - Constellation Units indicators
|
||||||
|
XP/Progress #3B82F6 Blue - Experience and progression
|
||||||
|
Activity #10B981 Green - Active users, live events
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
**Font Stack**
|
||||||
|
|
||||||
|
Primary (UI Text):
|
||||||
|
```css
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
```
|
||||||
|
- Clean, highly legible, modern
|
||||||
|
- Excellent at small sizes for data-dense interfaces
|
||||||
|
- Professional without being sterile
|
||||||
|
|
||||||
|
Display (Headings):
|
||||||
|
```css
|
||||||
|
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||||
|
```
|
||||||
|
- Geometric, slightly futuristic
|
||||||
|
- Use for page titles, section headers
|
||||||
|
- Reinforces celestial/institutional theme
|
||||||
|
|
||||||
|
Monospace (Data):
|
||||||
|
```css
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
```
|
||||||
|
- For numerical data, timestamps, IDs
|
||||||
|
- Improves scanability of tabular data
|
||||||
|
- Technical credibility
|
||||||
|
|
||||||
|
**Type Scale**
|
||||||
|
```
|
||||||
|
Display Large 48px / 3rem font-weight: 700 (Dashboard headers)
|
||||||
|
Display 36px / 2.25rem font-weight: 700 (Page titles)
|
||||||
|
Heading 1 30px / 1.875rem font-weight: 600 (Section titles)
|
||||||
|
Heading 2 24px / 1.5rem font-weight: 600 (Card headers)
|
||||||
|
Heading 3 20px / 1.25rem font-weight: 600 (Subsections)
|
||||||
|
Body Large 16px / 1rem font-weight: 400 (Emphasized body)
|
||||||
|
Body 14px / 0.875rem font-weight: 400 (Default text)
|
||||||
|
Body Small 13px / 0.8125rem font-weight: 400 (Secondary info)
|
||||||
|
Caption 12px / 0.75rem font-weight: 400 (Labels, hints)
|
||||||
|
Overline 11px / 0.6875rem font-weight: 600 (Uppercase labels)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Font Weight Usage**
|
||||||
|
- **700 (Bold)**: Display text, critical metrics
|
||||||
|
- **600 (Semibold)**: Headings, emphasized data
|
||||||
|
- **500 (Medium)**: Buttons, active tabs, selected items
|
||||||
|
- **400 (Regular)**: Body text, form inputs
|
||||||
|
- **Never use weights below 400** - maintain readability
|
||||||
|
|
||||||
|
### Spacing & Layout
|
||||||
|
|
||||||
|
**Base Unit**: 4px
|
||||||
|
|
||||||
|
**Spacing Scale**
|
||||||
|
```
|
||||||
|
xs 4px 0.25rem Tight spacing, icon gaps
|
||||||
|
sm 8px 0.5rem Form element spacing
|
||||||
|
md 16px 1rem Default component spacing
|
||||||
|
lg 24px 1.5rem Section spacing
|
||||||
|
xl 32px 2rem Major section breaks
|
||||||
|
2xl 48px 3rem Page section dividers
|
||||||
|
3xl 64px 4rem Major layout divisions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container Widths**
|
||||||
|
```
|
||||||
|
Full Bleed 100% Full viewport width
|
||||||
|
Wide 1600px Wide dashboards, data tables
|
||||||
|
Standard 1280px Default content width
|
||||||
|
Narrow 960px Forms, focused content
|
||||||
|
Reading 720px Long-form text (documentation)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grid System**
|
||||||
|
- 12-column grid for flexible layouts
|
||||||
|
- 24px gutters between columns
|
||||||
|
- Responsive breakpoints: 640px, 768px, 1024px, 1280px, 1536px
|
||||||
|
|
||||||
|
### Borders & Dividers
|
||||||
|
|
||||||
|
**Border Widths**
|
||||||
|
```
|
||||||
|
Hairline 0.5px Subtle dividers
|
||||||
|
Thin 1px Default borders
|
||||||
|
Medium 2px Emphasized borders, focus states
|
||||||
|
Thick 4px Accent bars, category indicators
|
||||||
|
```
|
||||||
|
|
||||||
|
**Border Colors**
|
||||||
|
```
|
||||||
|
Default #2D2A5F 15% opacity - Standard dividers
|
||||||
|
Subtle #2D2A5F 8% opacity - Very light separation
|
||||||
|
Emphasized #8B5CF6 30% opacity - Highlighted borders
|
||||||
|
Interactive #8B5CF6 60% opacity - Hover/focus states
|
||||||
|
```
|
||||||
|
|
||||||
|
**Border Radius**
|
||||||
|
```
|
||||||
|
None 0px Data tables, strict layouts
|
||||||
|
sm 4px Buttons, badges, pills
|
||||||
|
md 8px Cards, inputs, panels
|
||||||
|
lg 12px Large cards, modals
|
||||||
|
xl 16px Feature cards, images
|
||||||
|
2xl 24px Hero elements
|
||||||
|
full 9999px Circular elements, avatars
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Patterns
|
||||||
|
|
||||||
|
### Cards & Containers
|
||||||
|
|
||||||
|
**Standard Card**
|
||||||
|
```
|
||||||
|
Background: #151520 (Midnight Canvas)
|
||||||
|
Border: 1px solid rgba(139, 92, 246, 0.15)
|
||||||
|
Border Radius: 8px
|
||||||
|
Padding: 24px
|
||||||
|
Shadow: 0 4px 16px rgba(0, 0, 0, 0.4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Elevated Card** (hover/focus)
|
||||||
|
```
|
||||||
|
Background: #1E1B4B (Nebula Surface)
|
||||||
|
Border: 1px solid rgba(139, 92, 246, 0.3)
|
||||||
|
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
|
||||||
|
Transform: translateY(-2px)
|
||||||
|
Transition: all 200ms ease
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stat Card** (metrics, KPIs)
|
||||||
|
```
|
||||||
|
Background: Linear gradient from #151520 to #1E1B4B
|
||||||
|
Border: 1px solid rgba(139, 92, 246, 0.2)
|
||||||
|
Accent Border: 4px left border in tier/category color
|
||||||
|
Icon: Celestial icon in accent color
|
||||||
|
Typography: Large number (Display), small label (Overline)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Tables
|
||||||
|
|
||||||
|
**Table Structure**
|
||||||
|
```
|
||||||
|
Header Background: #1E1B4B
|
||||||
|
Header Text: #E5E7EB, 11px uppercase, 600 weight
|
||||||
|
Row Background: Alternating #0A0A0F / #151520
|
||||||
|
Row Hover: #2D2A5F with 40% opacity
|
||||||
|
Border: 1px solid rgba(139, 92, 246, 0.1) between rows
|
||||||
|
Cell Padding: 12px 16px
|
||||||
|
```
|
||||||
|
|
||||||
|
**Column Styling**
|
||||||
|
- Left-align text columns
|
||||||
|
- Right-align numerical columns
|
||||||
|
- Monospace font for numbers, IDs, timestamps
|
||||||
|
- Icon + text combinations for status indicators
|
||||||
|
|
||||||
|
**Interactive Elements**
|
||||||
|
- Sortable headers with subtle arrow icons
|
||||||
|
- Hover state on entire row
|
||||||
|
- Click/select highlight with Aurora Purple tint
|
||||||
|
- Pagination in Nebula Blue
|
||||||
|
|
||||||
|
### Forms & Inputs
|
||||||
|
|
||||||
|
**Input Fields**
|
||||||
|
```
|
||||||
|
Background: #1E1B4B
|
||||||
|
Border: 1px solid rgba(139, 92, 246, 0.2)
|
||||||
|
Border Radius: 6px
|
||||||
|
Padding: 10px 14px
|
||||||
|
Font Size: 14px
|
||||||
|
Text Color: #F9FAFB
|
||||||
|
|
||||||
|
Focus State:
|
||||||
|
Border: 2px solid #8B5CF6
|
||||||
|
Glow: 0 0 0 3px rgba(139, 92, 246, 0.2)
|
||||||
|
|
||||||
|
Error State:
|
||||||
|
Border: 1px solid #DC2626
|
||||||
|
Text: #DC2626 helper text below
|
||||||
|
|
||||||
|
Disabled State:
|
||||||
|
Background: #0A0A0F
|
||||||
|
Text: #6B7280
|
||||||
|
Cursor: not-allowed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Labels**
|
||||||
|
```
|
||||||
|
Font Size: 12px
|
||||||
|
Font Weight: 600
|
||||||
|
Text Color: #E5E7EB
|
||||||
|
Margin Bottom: 6px
|
||||||
|
```
|
||||||
|
|
||||||
|
**Select Dropdowns**
|
||||||
|
```
|
||||||
|
Same base styling as inputs
|
||||||
|
Dropdown Icon: Chevron in #9CA3AF
|
||||||
|
Menu Background: #2D2A5F
|
||||||
|
Menu Border: 1px solid rgba(139, 92, 246, 0.3)
|
||||||
|
Option Hover: #3B82F6 background
|
||||||
|
Selected: #8B5CF6 with checkmark icon
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checkboxes & Radio Buttons**
|
||||||
|
```
|
||||||
|
Size: 18px × 18px
|
||||||
|
Border: 2px solid rgba(139, 92, 246, 0.4)
|
||||||
|
Border Radius: 4px (checkbox) / 50% (radio)
|
||||||
|
Checked: #8B5CF6 background with white checkmark
|
||||||
|
Hover: Glow effect rgba(139, 92, 246, 0.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**Primary Button**
|
||||||
|
```
|
||||||
|
Background: #8B5CF6 (Aurora Purple)
|
||||||
|
Text: #FFFFFF
|
||||||
|
Padding: 10px 20px
|
||||||
|
Border Radius: 6px
|
||||||
|
Font Weight: 500
|
||||||
|
Shadow: 0 2px 8px rgba(139, 92, 246, 0.3)
|
||||||
|
|
||||||
|
Hover:
|
||||||
|
Background: #7C3AED (lighter purple)
|
||||||
|
Shadow: 0 4px 12px rgba(139, 92, 246, 0.4)
|
||||||
|
|
||||||
|
Active:
|
||||||
|
Background: #6D28D9 (darker purple)
|
||||||
|
Transform: scale(0.98)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secondary Button**
|
||||||
|
```
|
||||||
|
Background: transparent
|
||||||
|
Border: 1px solid rgba(139, 92, 246, 0.5)
|
||||||
|
Text: #8B5CF6
|
||||||
|
Padding: 10px 20px
|
||||||
|
|
||||||
|
Hover:
|
||||||
|
Background: rgba(139, 92, 246, 0.1)
|
||||||
|
Border: 1px solid #8B5CF6
|
||||||
|
```
|
||||||
|
|
||||||
|
**Destructive Button**
|
||||||
|
```
|
||||||
|
Background: #DC2626
|
||||||
|
Text: #FFFFFF
|
||||||
|
(Same structure as Primary)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ghost Button**
|
||||||
|
```
|
||||||
|
Background: transparent
|
||||||
|
Text: #E5E7EB
|
||||||
|
Padding: 8px 16px
|
||||||
|
|
||||||
|
Hover:
|
||||||
|
Background: rgba(139, 92, 246, 0.1)
|
||||||
|
Text: #8B5CF6
|
||||||
|
```
|
||||||
|
|
||||||
|
**Button Sizes**
|
||||||
|
```
|
||||||
|
Small 8px 12px 12px text
|
||||||
|
Medium 10px 20px 14px text (default)
|
||||||
|
Large 12px 24px 16px text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
**Sidebar Navigation**
|
||||||
|
```
|
||||||
|
Background: #0A0A0F with subtle gradient
|
||||||
|
Width: 260px (expanded) / 64px (collapsed)
|
||||||
|
Border Right: 1px solid rgba(139, 92, 246, 0.15)
|
||||||
|
|
||||||
|
Nav Item:
|
||||||
|
Padding: 12px 16px
|
||||||
|
Border Radius: 6px
|
||||||
|
Font Size: 14px
|
||||||
|
Font Weight: 500
|
||||||
|
Icon Size: 20px
|
||||||
|
Gap: 12px between icon and text
|
||||||
|
|
||||||
|
Active State:
|
||||||
|
Background: rgba(139, 92, 246, 0.15)
|
||||||
|
Border Left: 4px solid #8B5CF6
|
||||||
|
Text: #8B5CF6
|
||||||
|
Icon: #8B5CF6
|
||||||
|
|
||||||
|
Hover State:
|
||||||
|
Background: rgba(139, 92, 246, 0.08)
|
||||||
|
Text: #F9FAFB
|
||||||
|
```
|
||||||
|
|
||||||
|
**Top Bar / Header**
|
||||||
|
```
|
||||||
|
Background: #0A0A0F with backdrop blur
|
||||||
|
Height: 64px
|
||||||
|
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
|
||||||
|
Position: Sticky
|
||||||
|
Z-index: 100
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
- Logo / Academy name
|
||||||
|
- Global search
|
||||||
|
- Quick actions
|
||||||
|
- User profile dropdown
|
||||||
|
- Notification bell
|
||||||
|
```
|
||||||
|
|
||||||
|
**Breadcrumbs**
|
||||||
|
```
|
||||||
|
Font Size: 13px
|
||||||
|
Text Color: #9CA3AF
|
||||||
|
Separator: "/" or "›" in #6B7280
|
||||||
|
Current Page: #F9FAFB, 600 weight
|
||||||
|
Links: #9CA3AF, hover to #8B5CF6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modals & Overlays
|
||||||
|
|
||||||
|
**Modal Structure**
|
||||||
|
```
|
||||||
|
Backdrop: rgba(0, 0, 0, 0.8) with backdrop blur
|
||||||
|
Modal Container: #151520
|
||||||
|
Border: 1px solid rgba(139, 92, 246, 0.2)
|
||||||
|
Border Radius: 12px
|
||||||
|
Shadow: 0 24px 48px rgba(0, 0, 0, 0.9)
|
||||||
|
Max Width: 600px (standard) / 900px (wide)
|
||||||
|
Padding: 32px
|
||||||
|
|
||||||
|
Header:
|
||||||
|
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
|
||||||
|
Padding: 0 0 20px 0
|
||||||
|
Font Size: 24px
|
||||||
|
Font Weight: 600
|
||||||
|
|
||||||
|
Footer:
|
||||||
|
Border Top: 1px solid rgba(139, 92, 246, 0.15)
|
||||||
|
Padding: 20px 0 0 0
|
||||||
|
Buttons: Right-aligned, 12px gap
|
||||||
|
```
|
||||||
|
|
||||||
|
**Toast Notifications**
|
||||||
|
```
|
||||||
|
Position: Top-right, 24px margin
|
||||||
|
Background: #2D2A5F
|
||||||
|
Border: 1px solid (color based on type)
|
||||||
|
Border Radius: 8px
|
||||||
|
Padding: 16px 20px
|
||||||
|
Max Width: 400px
|
||||||
|
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
|
||||||
|
|
||||||
|
Success: #10B981 border, green icon
|
||||||
|
Warning: #F59E0B border, amber icon
|
||||||
|
Error: #DC2626 border, red icon
|
||||||
|
Info: #3B82F6 border, blue icon
|
||||||
|
|
||||||
|
Animation: Slide in from right, fade out
|
||||||
|
Duration: 4 seconds (dismissible)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Visualization
|
||||||
|
|
||||||
|
**Charts & Graphs**
|
||||||
|
```
|
||||||
|
Background: #151520 or transparent
|
||||||
|
Grid Lines: rgba(139, 92, 246, 0.1)
|
||||||
|
Axis Labels: #9CA3AF, 12px
|
||||||
|
Data Points: Constellation tier colors or semantic colors
|
||||||
|
Tooltips: #2D2A5F background, white text
|
||||||
|
Legend: Horizontal, 12px, icons + labels
|
||||||
|
```
|
||||||
|
|
||||||
|
**Progress Bars**
|
||||||
|
```
|
||||||
|
Track: #1E1B4B
|
||||||
|
Fill: Linear gradient with tier/category color
|
||||||
|
Height: 8px (thin) / 12px (medium) / 16px (thick)
|
||||||
|
Border Radius: 9999px
|
||||||
|
Label: Above or inline, monospace numbers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Badges & Pills**
|
||||||
|
```
|
||||||
|
Background: Semantic color with 15% opacity
|
||||||
|
Text: Semantic color (full saturation)
|
||||||
|
Border: 1px solid semantic color with 30% opacity
|
||||||
|
Padding: 4px 10px
|
||||||
|
Border Radius: 9999px
|
||||||
|
Font Size: 12px
|
||||||
|
Font Weight: 500
|
||||||
|
|
||||||
|
Status Examples:
|
||||||
|
Active: Green
|
||||||
|
Pending: Amber
|
||||||
|
Inactive: Gray
|
||||||
|
Error: Red
|
||||||
|
Premium: Gold
|
||||||
|
```
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
**Icon System**
|
||||||
|
- Use consistent icon family (e.g., Lucide, Heroicons, Phosphor)
|
||||||
|
- Line-style icons, not filled (except for active states)
|
||||||
|
- Stroke width: 1.5px-2px
|
||||||
|
- Sizes: 16px (small), 20px (default), 24px (large), 32px (extra large)
|
||||||
|
|
||||||
|
**Icon Colors**
|
||||||
|
- Default: #9CA3AF (Cosmic Gray)
|
||||||
|
- Active/Selected: #8B5CF6 (Aurora Purple)
|
||||||
|
- Success: #10B981
|
||||||
|
- Warning: #F59E0B
|
||||||
|
- Error: #DC2626
|
||||||
|
|
||||||
|
**Celestial Icon Themes**
|
||||||
|
- Stars, constellations, orbits for branding
|
||||||
|
- Minimalist, geometric line art
|
||||||
|
- Avoid overly detailed or realistic astronomy images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation & Motion
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
- **Purposeful**: Animations guide attention and provide feedback
|
||||||
|
- **Subtle**: No distracting or excessive motion
|
||||||
|
- **Fast**: Snappy interactions (150-300ms)
|
||||||
|
- **Professional**: Ease curves that feel polished
|
||||||
|
|
||||||
|
### Timing Functions
|
||||||
|
```
|
||||||
|
ease-out Default for most interactions
|
||||||
|
ease-in-out Modal/panel transitions
|
||||||
|
ease-in Exit animations
|
||||||
|
spring Micro-interactions (subtle bounce)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard Durations
|
||||||
|
```
|
||||||
|
Instant 0ms State changes
|
||||||
|
Fast 150ms Button hover, color changes
|
||||||
|
Standard 200ms Card hover, dropdown open
|
||||||
|
Moderate 300ms Modal open, page transitions
|
||||||
|
Slow 500ms Large panel animations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Animations
|
||||||
|
|
||||||
|
**Hover Effects**
|
||||||
|
```css
|
||||||
|
transition: all 200ms ease-out;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: [enhanced shadow];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Focus States**
|
||||||
|
```css
|
||||||
|
transition: border 150ms ease-out, box-shadow 150ms ease-out;
|
||||||
|
border-color: #8B5CF6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Loading States**
|
||||||
|
```
|
||||||
|
Skeleton: Shimmer effect from left to right
|
||||||
|
Spinner: Rotating celestial icon or ring
|
||||||
|
Progress: Smooth bar fill with easing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Page Transitions**
|
||||||
|
```
|
||||||
|
Fade in: Opacity 0 → 1 over 200ms
|
||||||
|
Slide up: TranslateY(20px) → 0 over 300ms
|
||||||
|
Blur fade: Blur + opacity for backdrop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
```
|
||||||
|
Mobile < 640px
|
||||||
|
Tablet 640px - 1024px
|
||||||
|
Desktop > 1024px
|
||||||
|
Wide Desktop > 1536px
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile Adaptations
|
||||||
|
- Sidebar collapses to hamburger menu
|
||||||
|
- Cards stack vertically
|
||||||
|
- Tables become horizontally scrollable or convert to card view
|
||||||
|
- Reduce padding and spacing by 25-50%
|
||||||
|
- Larger touch targets (minimum 44px)
|
||||||
|
- Bottom navigation for primary actions
|
||||||
|
|
||||||
|
### Tablet Optimizations
|
||||||
|
- Hybrid layouts (sidebar can be toggled)
|
||||||
|
- Adaptive grid (4 columns → 2 columns)
|
||||||
|
- Touch-friendly sizing maintained
|
||||||
|
- Utilize available space efficiently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
### Color Contrast
|
||||||
|
- Maintain WCAG AA standards minimum (4.5:1 for normal text)
|
||||||
|
- Critical actions and text meet AAA standards (7:1)
|
||||||
|
- Never rely on color alone for information
|
||||||
|
|
||||||
|
### Focus Indicators
|
||||||
|
- Always visible focus states
|
||||||
|
- 2px Aurora Purple outline with 3px glow
|
||||||
|
- Logical tab order follows visual hierarchy
|
||||||
|
|
||||||
|
### Screen Readers
|
||||||
|
- Semantic HTML structure
|
||||||
|
- ARIA labels for icon-only buttons
|
||||||
|
- Status messages announced appropriately
|
||||||
|
- Table headers properly associated
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
- All interactive elements accessible via keyboard
|
||||||
|
- Modal traps focus within itself
|
||||||
|
- Escape key closes overlays
|
||||||
|
- Arrow keys for navigation where appropriate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Mode Philosophy
|
||||||
|
|
||||||
|
**Aurora Admin is dark-first by design.** The interface assumes a dark environment and doesn't offer a light mode toggle. This decision is intentional:
|
||||||
|
|
||||||
|
- **Focus**: Dark reduces eye strain during extended admin sessions
|
||||||
|
- **Data Emphasis**: Light text on dark makes numbers/data more prominent
|
||||||
|
- **Celestial Theme**: Dark backgrounds reinforce the cosmic aesthetic
|
||||||
|
- **Professional**: Dark UIs feel more serious and technical
|
||||||
|
|
||||||
|
If light mode is ever required, avoid pure white—use off-white (#F9FAFB) backgrounds with careful contrast management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theming & Customization
|
||||||
|
|
||||||
|
### Constellation Tier Theming
|
||||||
|
When displaying constellation-specific data:
|
||||||
|
- Use tier colors for accents, not backgrounds
|
||||||
|
- Apply colors to borders, icons, badges
|
||||||
|
- Maintain readability—don't overwhelm with color
|
||||||
|
|
||||||
|
### Admin Privilege Levels
|
||||||
|
Different admin roles can have subtle UI indicators:
|
||||||
|
- Super Admin: Gold accents
|
||||||
|
- Moderator: Purple accents
|
||||||
|
- Viewer: Blue accents
|
||||||
|
|
||||||
|
These are subtle hints, not dominant visual themes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Library Standards
|
||||||
|
|
||||||
|
### Consistency
|
||||||
|
- Reuse components extensively
|
||||||
|
- Maintain consistent spacing, sizing, behavior
|
||||||
|
- Document component variants clearly
|
||||||
|
- Avoid one-off custom elements
|
||||||
|
|
||||||
|
### Composability
|
||||||
|
- Build complex UIs from simple components
|
||||||
|
- Components should work together seamlessly
|
||||||
|
- Predictable prop APIs
|
||||||
|
- Flexible but not overly configurable
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Lazy load heavy components
|
||||||
|
- Virtualize long lists
|
||||||
|
- Optimize re-renders
|
||||||
|
- Compress and cache assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style (UI Framework Agnostic)
|
||||||
|
|
||||||
|
### Class Naming
|
||||||
|
Use clear, semantic names:
|
||||||
|
```
|
||||||
|
.card-stat Not .cs or .c1
|
||||||
|
.button-primary Not .btn-p or .bp
|
||||||
|
.table-header Not .th or .t-h
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Organization
|
||||||
|
```
|
||||||
|
/components
|
||||||
|
/ui Base components (buttons, inputs)
|
||||||
|
/layout Layout components (sidebar, header)
|
||||||
|
/data Data components (tables, charts)
|
||||||
|
/feedback Toasts, modals, alerts
|
||||||
|
/forms Form-specific components
|
||||||
|
```
|
||||||
|
|
||||||
|
### Style Organization
|
||||||
|
- Variables/tokens for all design values
|
||||||
|
- No magic numbers in components
|
||||||
|
- DRY—reuse common styles
|
||||||
|
- Mobile-first responsive approach
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Do's ✓
|
||||||
|
- Use established patterns from these guidelines
|
||||||
|
- Maintain consistent spacing throughout
|
||||||
|
- Prioritize data clarity and scannability
|
||||||
|
- Test with real data, not lorem ipsum
|
||||||
|
- Consider loading and empty states
|
||||||
|
- Provide clear feedback for all actions
|
||||||
|
- Use progressive disclosure for complex features
|
||||||
|
|
||||||
|
### Don'ts ✗
|
||||||
|
- Don't use bright, saturated colors outside defined palette
|
||||||
|
- Don't create custom components when standard ones exist
|
||||||
|
- Don't sacrifice accessibility for aesthetics
|
||||||
|
- Don't use decorative animations that distract
|
||||||
|
- Don't hide critical actions in nested menus
|
||||||
|
- Don't use tiny fonts (below 12px) for functional text
|
||||||
|
- Don't ignore error states and edge cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
Before considering any UI complete:
|
||||||
|
|
||||||
|
**Visual**
|
||||||
|
- [ ] Colors match defined palette exactly
|
||||||
|
- [ ] Spacing uses the 4px grid system
|
||||||
|
- [ ] Typography follows scale and hierarchy
|
||||||
|
- [ ] Borders and shadows are consistent
|
||||||
|
- [ ] Icons are properly sized and aligned
|
||||||
|
|
||||||
|
**Interaction**
|
||||||
|
- [ ] Hover states are defined for all interactive elements
|
||||||
|
- [ ] Focus states are visible and clear
|
||||||
|
- [ ] Loading states prevent user confusion
|
||||||
|
- [ ] Success/error feedback is immediate
|
||||||
|
- [ ] Animations are smooth and purposeful
|
||||||
|
|
||||||
|
**Responsive**
|
||||||
|
- [ ] Layout adapts to mobile, tablet, desktop
|
||||||
|
- [ ] Touch targets are minimum 44px on mobile
|
||||||
|
- [ ] Text remains readable at all sizes
|
||||||
|
- [ ] No horizontal scrolling (except intentional)
|
||||||
|
|
||||||
|
**Accessibility**
|
||||||
|
- [ ] Keyboard navigation works completely
|
||||||
|
- [ ] Focus indicators are always visible
|
||||||
|
- [ ] Color contrast meets WCAG AA minimum
|
||||||
|
- [ ] ARIA labels present where needed
|
||||||
|
- [ ] Screen reader tested for critical flows
|
||||||
|
|
||||||
|
**Data**
|
||||||
|
- [ ] Empty states are handled gracefully
|
||||||
|
- [ ] Error states provide actionable guidance
|
||||||
|
- [ ] Large datasets perform well
|
||||||
|
- [ ] Loading states prevent layout shift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Assets
|
||||||
|
|
||||||
|
### Suggested Icon Library
|
||||||
|
- **Lucide Icons**: Clean, consistent, extensive
|
||||||
|
- **Heroicons**: Tailwind-friendly, well-designed
|
||||||
|
- **Phosphor Icons**: Flexible weights and styles
|
||||||
|
|
||||||
|
### Font Resources
|
||||||
|
- **Inter**: [Google Fonts](https://fonts.google.com/specimen/Inter)
|
||||||
|
- **Space Grotesk**: [Google Fonts](https://fonts.google.com/specimen/Space+Grotesk)
|
||||||
|
- **JetBrains Mono**: [JetBrains](https://www.jetbrains.com/lp/mono/)
|
||||||
|
|
||||||
|
### Design Tools
|
||||||
|
- Use component libraries: shadcn/ui, Headless UI, Radix
|
||||||
|
- Tailwind CSS for utility-first styling
|
||||||
|
- CSS variables for theming
|
||||||
|
- Design tokens for consistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The Aurora Admin Panel is a sophisticated tool that demands respect through its design. Every pixel serves a purpose—whether to inform, to guide, or to reinforce the prestige of the academy it administers.
|
||||||
|
|
||||||
|
**Design with authority. Build with precision. Maintain the standard.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*These design guidelines are living documentation. As Aurora evolves, so too should these standards. Propose updates through the standard development workflow.*
|
||||||
@@ -9,19 +9,22 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.6.1"
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"daisyui": "^5.0.43",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,82 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { useState } from "react";
|
||||||
import { useAuth } from "./lib/useAuth";
|
import { useAuth } from "./lib/useAuth";
|
||||||
import Layout from "./components/Layout";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import Layout, { type Page } from "./components/Layout";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Items from "./pages/Items";
|
import PlaceholderPage from "./pages/PlaceholderPage";
|
||||||
import Quests from "./pages/Quests";
|
|
||||||
import Classes from "./pages/Classes";
|
const placeholders: Record<string, { title: string; description: string }> = {
|
||||||
import Users from "./pages/Users";
|
users: {
|
||||||
import Settings from "./pages/Settings";
|
title: "Users",
|
||||||
import Lootdrops from "./pages/Lootdrops";
|
description: "Search, view, and manage user accounts, balances, XP, levels, and inventories.",
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
title: "Items",
|
||||||
|
description: "Create, edit, and manage game items with icons, rarities, and pricing.",
|
||||||
|
},
|
||||||
|
classes: {
|
||||||
|
title: "Classes",
|
||||||
|
description: "Manage academy classes, assign Discord roles, and track class balances.",
|
||||||
|
},
|
||||||
|
quests: {
|
||||||
|
title: "Quests",
|
||||||
|
description: "Configure quests with trigger events, targets, and XP/balance rewards.",
|
||||||
|
},
|
||||||
|
lootdrops: {
|
||||||
|
title: "Lootdrops",
|
||||||
|
description: "View active lootdrops, spawn new drops, and manage lootdrop history.",
|
||||||
|
},
|
||||||
|
moderation: {
|
||||||
|
title: "Moderation",
|
||||||
|
description: "Review moderation cases — warnings, timeouts, kicks, bans — and manage appeals.",
|
||||||
|
},
|
||||||
|
transactions: {
|
||||||
|
title: "Transactions",
|
||||||
|
description: "Browse the economy transaction log with filtering by user, type, and date.",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: "Settings",
|
||||||
|
description: "Configure bot settings for economy, leveling, commands, and guild preferences.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loading, user, logout } = useAuth();
|
const { loading, user, logout } = useAuth();
|
||||||
|
const [page, setPage] = useState<Page>("dashboard");
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<span className="loading loading-spinner loading-lg" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="card bg-base-100 shadow-xl p-8 text-center max-w-sm">
|
<div className="text-center max-w-xs">
|
||||||
<h1 className="text-2xl font-bold mb-2">Aurora Admin Panel</h1>
|
<div className="font-display font-bold text-3xl tracking-tight mb-1">Aurora</div>
|
||||||
<p className="text-base-content/60 mb-6">Sign in with Discord to continue.</p>
|
<p className="text-sm text-muted-foreground mb-8">Admin Panel</p>
|
||||||
<a href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`} className="btn btn-primary">
|
<a
|
||||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`}
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03z" />
|
className="inline-flex items-center justify-center w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
</svg>
|
>
|
||||||
Sign in with Discord
|
Sign in with Discord
|
||||||
</a>
|
</a>
|
||||||
|
<p className="text-xs text-muted-foreground/40 mt-6">Authorized administrators only</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
|
||||||
<Route element={<Layout user={user} onLogout={logout} />}>
|
{page === "dashboard" ? (
|
||||||
<Route index element={<Dashboard />} />
|
<Dashboard />
|
||||||
<Route path="items" element={<Items />} />
|
) : (
|
||||||
<Route path="quests" element={<Quests />} />
|
<PlaceholderPage {...placeholders[page]!} />
|
||||||
<Route path="classes" element={<Classes />} />
|
)}
|
||||||
<Route path="users" element={<Users />} />
|
</Layout>
|
||||||
<Route path="settings" element={<Settings />} />
|
|
||||||
<Route path="lootdrops" element={<Lootdrops />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export interface Column<T> {
|
|
||||||
key: string;
|
|
||||||
header: string;
|
|
||||||
render?: (row: T) => ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataTableProps<T> {
|
|
||||||
columns: Column<T>[];
|
|
||||||
data: T[];
|
|
||||||
keyField: string;
|
|
||||||
loading?: boolean;
|
|
||||||
onRowClick?: (row: T) => void;
|
|
||||||
emptyMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DataTable<T extends Record<string, unknown>>({
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
keyField,
|
|
||||||
loading,
|
|
||||||
onRowClick,
|
|
||||||
emptyMessage = "No data found",
|
|
||||||
}: DataTableProps<T>) {
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center p-8">
|
|
||||||
<span className="loading loading-spinner loading-lg" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="table table-zebra w-full">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{columns.map((col) => (
|
|
||||||
<th key={col.key} className={col.className}>
|
|
||||||
{col.header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={columns.length} className="text-center py-8 text-base-content/50">
|
|
||||||
{emptyMessage}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
data.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={String(row[keyField])}
|
|
||||||
className={onRowClick ? "cursor-pointer hover" : ""}
|
|
||||||
onClick={() => onRowClick?.(row)}
|
|
||||||
>
|
|
||||||
{columns.map((col) => (
|
|
||||||
<td key={col.key} className={col.className}>
|
|
||||||
{col.render ? col.render(row) : String(row[col.key] ?? "")}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +1,139 @@
|
|||||||
import { NavLink, Outlet } from "react-router-dom";
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Package,
|
||||||
|
Shield,
|
||||||
|
Scroll,
|
||||||
|
Gift,
|
||||||
|
ArrowLeftRight,
|
||||||
|
GraduationCap,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
import type { AuthUser } from "../lib/useAuth";
|
import type { AuthUser } from "../lib/useAuth";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
export type Page =
|
||||||
{ to: "/", label: "Dashboard", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" },
|
| "dashboard"
|
||||||
{ to: "/items", label: "Items", icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" },
|
| "users"
|
||||||
{ to: "/quests", label: "Quests", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" },
|
| "items"
|
||||||
{ to: "/classes", label: "Classes", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" },
|
| "classes"
|
||||||
{ to: "/users", label: "Users", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" },
|
| "quests"
|
||||||
{ to: "/settings", label: "Settings", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z" },
|
| "lootdrops"
|
||||||
{ to: "/lootdrops", label: "Lootdrops", icon: "M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" },
|
| "moderation"
|
||||||
];
|
| "transactions"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
function avatarUrl(user: AuthUser): string {
|
const navItems: { page: Page; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||||
if (user.avatar) {
|
{ page: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`;
|
{ page: "users", label: "Users", icon: Users },
|
||||||
}
|
{ page: "items", label: "Items", icon: Package },
|
||||||
const index = (BigInt(user.discordId) >> 22n) % 6n;
|
{ page: "classes", label: "Classes", icon: GraduationCap },
|
||||||
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
{ page: "quests", label: "Quests", icon: Scroll },
|
||||||
}
|
{ page: "lootdrops", label: "Lootdrops", icon: Gift },
|
||||||
|
{ page: "moderation", label: "Moderation", icon: Shield },
|
||||||
|
{ page: "transactions", label: "Transactions", icon: ArrowLeftRight },
|
||||||
|
{ page: "settings", label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Layout({
|
export default function Layout({
|
||||||
user,
|
user,
|
||||||
onLogout,
|
logout,
|
||||||
|
currentPage,
|
||||||
|
onNavigate,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onLogout: () => void;
|
logout: () => Promise<void>;
|
||||||
|
currentPage: Page;
|
||||||
|
onNavigate: (page: Page) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const avatarUrl = user.avatar
|
||||||
|
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-base-200">
|
<div className="min-h-screen flex">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-64 bg-base-300 flex flex-col">
|
<aside
|
||||||
<div className="p-4 font-bold text-xl border-b border-base-content/10">
|
className={cn(
|
||||||
Aurora Panel
|
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
|
||||||
|
collapsed ? "w-16" : "w-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center h-16 px-4 border-b border-border">
|
||||||
|
<div className="font-display text-xl font-bold tracking-tight">
|
||||||
|
{collapsed ? "A" : "Aurora"}
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 p-2 space-y-1">
|
</div>
|
||||||
{NAV_ITEMS.map((item) => (
|
|
||||||
<NavLink
|
{/* Nav items */}
|
||||||
key={item.to}
|
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
|
||||||
to={item.to}
|
{navItems.map(({ page, label, icon: Icon }) => (
|
||||||
end={item.to === "/"}
|
<button
|
||||||
className={({ isActive }) =>
|
key={page}
|
||||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
onClick={() => onNavigate(page)}
|
||||||
isActive
|
className={cn(
|
||||||
? "bg-primary text-primary-content"
|
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
: "hover:bg-base-content/10"
|
currentPage === page
|
||||||
}`
|
? "bg-primary/15 text-primary border-l-4 border-primary"
|
||||||
}
|
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<svg
|
<Icon className={cn("w-5 h-5 shrink-0", currentPage === page && "text-primary")} />
|
||||||
className="w-5 h-5 shrink-0"
|
{!collapsed && <span>{label}</span>}
|
||||||
fill="none"
|
</button>
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
|
|
||||||
</svg>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-3 border-t border-base-content/10">
|
|
||||||
<div className="flex items-center gap-3">
|
{/* User & collapse */}
|
||||||
<img
|
<div className="border-t border-border p-3 space-y-2">
|
||||||
src={avatarUrl(user)}
|
{!collapsed && (
|
||||||
className="w-8 h-8 rounded-full"
|
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||||
alt=""
|
{avatarUrl ? (
|
||||||
/>
|
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
|
||||||
<span className="text-sm font-medium flex-1 truncate">
|
) : (
|
||||||
{user.username}
|
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
|
||||||
</span>
|
{user.username[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{user.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn("flex", collapsed ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={logout}
|
||||||
className="btn btn-ghost btn-xs"
|
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
title="Logout"
|
title="Sign out"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<LogOut className="w-4 h-4" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
{!collapsed && <span>Sign out</span>}
|
||||||
</svg>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
|
||||||
|
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}>
|
||||||
<Outlet />
|
<div className="max-w-[1600px] mx-auto px-6 py-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface ModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
|
||||||
actions?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Modal({ open, onClose, title, children, actions }: ModalProps) {
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<dialog className="modal modal-open">
|
|
||||||
<div className="modal-box max-w-2xl">
|
|
||||||
<h3 className="font-bold text-lg mb-4">{title}</h3>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
{children}
|
|
||||||
{actions && <div className="modal-action">{actions}</div>}
|
|
||||||
</div>
|
|
||||||
<form method="dialog" className="modal-backdrop">
|
|
||||||
<button onClick={onClose}>close</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,51 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui";
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: #0A0A0F;
|
||||||
|
--color-foreground: #F9FAFB;
|
||||||
|
--color-muted: #151520;
|
||||||
|
--color-muted-foreground: #9CA3AF;
|
||||||
|
--color-border: rgba(139, 92, 246, 0.15);
|
||||||
|
--color-input: #1E1B4B;
|
||||||
|
--color-ring: #8B5CF6;
|
||||||
|
--color-primary: #8B5CF6;
|
||||||
|
--color-primary-foreground: #FFFFFF;
|
||||||
|
--color-secondary: #1E1B4B;
|
||||||
|
--color-secondary-foreground: #F9FAFB;
|
||||||
|
--color-accent: #2D2A5F;
|
||||||
|
--color-accent-foreground: #F9FAFB;
|
||||||
|
--color-destructive: #DC2626;
|
||||||
|
--color-destructive-foreground: #FFFFFF;
|
||||||
|
--color-card: #151520;
|
||||||
|
--color-card-foreground: #F9FAFB;
|
||||||
|
--color-success: #10B981;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-info: #3B82F6;
|
||||||
|
--color-gold: #FCD34D;
|
||||||
|
--color-surface: #1E1B4B;
|
||||||
|
--color-raised: #2D2A5F;
|
||||||
|
--color-text-secondary: #E5E7EB;
|
||||||
|
--color-text-tertiary: #9CA3AF;
|
||||||
|
--color-text-disabled: #6B7280;
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
|
||||||
|
--font-display: 'Space Grotesk', 'Inter', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|||||||
51
panel/src/lib/useDashboard.ts
Normal file
51
panel/src/lib/useDashboard.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { get } from "./api";
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
bot: { name: string; avatarUrl: string | null; status: string | null };
|
||||||
|
guilds: { count: number };
|
||||||
|
users: { active: number; total: number };
|
||||||
|
commands: { total: number; active: number; disabled: number };
|
||||||
|
ping: { avg: number };
|
||||||
|
economy: {
|
||||||
|
totalWealth: string;
|
||||||
|
avgLevel: number;
|
||||||
|
topStreak: number;
|
||||||
|
totalItems?: number;
|
||||||
|
};
|
||||||
|
recentEvents: Array<{
|
||||||
|
type: "success" | "error" | "info" | "warn";
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
icon?: string;
|
||||||
|
}>;
|
||||||
|
activeLootdrops?: Array<{
|
||||||
|
rewardAmount: number;
|
||||||
|
currency: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}>;
|
||||||
|
leaderboards?: {
|
||||||
|
topLevels: Array<{ username: string; level: number }>;
|
||||||
|
topWealth: Array<{ username: string; balance: string }>;
|
||||||
|
topNetWorth: Array<{ username: string; netWorth: string }>;
|
||||||
|
};
|
||||||
|
uptime: number;
|
||||||
|
lastCommandTimestamp: number | null;
|
||||||
|
maintenanceMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
const [data, setData] = useState<DashboardStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
get<DashboardStats>("/api/stats")
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.error ?? "Failed to load stats"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, loading, error };
|
||||||
|
}
|
||||||
6
panel/src/lib/utils.ts
Normal file
6
panel/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { get, post, put, del } from "../lib/api";
|
|
||||||
import DataTable, { type Column } from "../components/DataTable";
|
|
||||||
import Modal from "../components/Modal";
|
|
||||||
|
|
||||||
interface GameClass {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
balance: string;
|
|
||||||
roleId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClassesResponse {
|
|
||||||
classes: GameClass[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Classes() {
|
|
||||||
const [classes, setClasses] = useState<GameClass[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<GameClass | null>(null);
|
|
||||||
const [form, setForm] = useState<{ id?: string; name: string; balance: string; roleId: string | null }>({ name: "", balance: "0", roleId: null });
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchClasses = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
get<ClassesResponse>("/api/classes")
|
|
||||||
.then((data) => setClasses(data.classes))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchClasses(); }, [fetchClasses]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
setForm({ id: "", name: "", balance: "0", roleId: null });
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (cls: GameClass) => {
|
|
||||||
setEditing(cls);
|
|
||||||
setForm({ name: cls.name, balance: cls.balance, roleId: cls.roleId });
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
if (editing) {
|
|
||||||
await put(`/api/classes/${editing.id}`, { name: form.name, balance: form.balance, roleId: form.roleId });
|
|
||||||
} else {
|
|
||||||
await post("/api/classes", { id: form.id, name: form.name, balance: form.balance, roleId: form.roleId });
|
|
||||||
}
|
|
||||||
setModalOpen(false);
|
|
||||||
fetchClasses();
|
|
||||||
} catch (e) {
|
|
||||||
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (cls: GameClass) => {
|
|
||||||
if (!confirm(`Delete "${cls.name}"?`)) return;
|
|
||||||
await del(`/api/classes/${cls.id}`);
|
|
||||||
fetchClasses();
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: Column<GameClass>[] = [
|
|
||||||
{ key: "id", header: "ID", className: "w-24" },
|
|
||||||
{ key: "name", header: "Name" },
|
|
||||||
{ key: "balance", header: "Balance", render: (r) => BigInt(r.balance).toLocaleString() },
|
|
||||||
{ key: "roleId", header: "Role ID", render: (r) => r.roleId ?? "—" },
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "",
|
|
||||||
className: "w-24",
|
|
||||||
render: (row) => (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
|
|
||||||
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h1 className="text-2xl font-bold">Classes</h1>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Class</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable columns={columns} data={classes as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={modalOpen}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
title={editing ? `Edit: ${editing.name}` : "New Class"}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{!editing && (
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">ID (Discord role snowflake or unique number)</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={form.id ?? ""} onChange={(e) => setForm({ ...form, id: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Name</span></label>
|
|
||||||
<input className="input input-bordered input-sm" maxLength={50} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Balance</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={form.balance} onChange={(e) => setForm({ ...form, balance: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Role ID (Discord)</span></label>
|
|
||||||
<input className="input input-bordered input-sm" placeholder="Optional" value={form.roleId ?? ""} onChange={(e) => setForm({ ...form, roleId: e.target.value || null })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +1,297 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import {
|
||||||
import { get } from "../lib/api";
|
Users,
|
||||||
|
Coins,
|
||||||
|
TrendingUp,
|
||||||
|
Gift,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Info,
|
||||||
|
Clock,
|
||||||
|
Wifi,
|
||||||
|
Trophy,
|
||||||
|
Crown,
|
||||||
|
Gem,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { useDashboard, type DashboardStats } from "../lib/useDashboard";
|
||||||
|
|
||||||
interface Stats {
|
function formatNumber(n: number | string): string {
|
||||||
bot: { name: string; avatarUrl: string | null; status: string | null };
|
const num = typeof n === "string" ? parseFloat(n) : n;
|
||||||
guilds: { count: number };
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
users: { total: number; active: number };
|
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||||
economy: { totalWealth: string; avgLevel: number; topStreak: number; totalItems: number };
|
return num.toLocaleString();
|
||||||
commands: { total: number; active: number; disabled: number };
|
}
|
||||||
ping: { avg: number };
|
|
||||||
uptime: number;
|
function formatUptime(ms: number): string {
|
||||||
|
const hours = Math.floor(ms / 3_600_000);
|
||||||
|
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
||||||
|
if (hours >= 24) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(ts: string | Date): string {
|
||||||
|
const diff = Date.now() - new Date(ts).getTime();
|
||||||
|
const mins = Math.floor(diff / 60_000);
|
||||||
|
if (mins < 1) return "just now";
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventIcons = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
warn: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const eventColors = {
|
||||||
|
success: "text-success",
|
||||||
|
error: "text-destructive",
|
||||||
|
warn: "text-warning",
|
||||||
|
info: "text-info",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent = "border-primary",
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-gradient-to-br from-card to-surface rounded-lg border border-border p-6 border-l-4",
|
||||||
|
accent
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold font-display tracking-tight">{value}</div>
|
||||||
|
{sub && <div className="text-sm text-text-tertiary mt-1">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderboardColumn({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
entries,
|
||||||
|
valueKey,
|
||||||
|
valuePrefix,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
entries: Array<{ username: string; [k: string]: unknown }>;
|
||||||
|
valueKey: string;
|
||||||
|
valuePrefix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-lg border border-border">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
|
<Icon className="w-4 h-4 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="px-5 py-4 text-sm text-text-tertiary">No data</div>
|
||||||
|
)}
|
||||||
|
{entries.slice(0, 10).map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={entry.username}
|
||||||
|
className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-6 text-xs font-mono font-medium text-right",
|
||||||
|
i === 0
|
||||||
|
? "text-gold"
|
||||||
|
: i === 1
|
||||||
|
? "text-text-secondary"
|
||||||
|
: i === 2
|
||||||
|
? "text-warning"
|
||||||
|
: "text-text-tertiary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{entry.username}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-text-secondary">
|
||||||
|
{valuePrefix}
|
||||||
|
{formatNumber(entry[valueKey] as string | number)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const { data, loading, error } = useDashboard();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
get<Stats>("/api/stats")
|
|
||||||
.then(setStats)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
|
|
||||||
// Connect WebSocket for live updates
|
|
||||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(e.data);
|
|
||||||
if (msg.type === "STATS_UPDATE") setStats(msg.data);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => ws.close();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center p-12">
|
<div className="flex items-center justify-center py-32">
|
||||||
<span className="loading loading-spinner loading-lg" />
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stats) return <div className="alert alert-error">Failed to load stats</div>;
|
if (error) {
|
||||||
|
|
||||||
const uptimeHours = Math.floor((stats.uptime ?? 0) / 3600);
|
|
||||||
const uptimeMins = Math.floor(((stats.uptime ?? 0) % 3600) / 60);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex items-center justify-center py-32">
|
||||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
<div className="text-center">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-3" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<p className="text-sm text-text-tertiary">{error}</p>
|
||||||
<div className="stat bg-base-100 rounded-box shadow">
|
|
||||||
<div className="stat-title">Uptime</div>
|
|
||||||
<div className="stat-value text-lg">{uptimeHours}h {uptimeMins}m</div>
|
|
||||||
<div className="stat-desc">Ping: {stats.ping?.avg ?? 0}ms</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat bg-base-100 rounded-box shadow">
|
|
||||||
<div className="stat-title">Guilds</div>
|
|
||||||
<div className="stat-value text-lg">{stats.guilds?.count ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat bg-base-100 rounded-box shadow">
|
|
||||||
<div className="stat-title">Users</div>
|
|
||||||
<div className="stat-value text-lg">{stats.users?.total ?? 0}</div>
|
|
||||||
<div className="stat-desc">{stats.users?.active ?? 0} active</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat bg-base-100 rounded-box shadow">
|
|
||||||
<div className="stat-title">Economy</div>
|
|
||||||
<div className="stat-value text-lg">{Number(stats.economy?.totalWealth ?? 0).toLocaleString()}g</div>
|
|
||||||
<div className="stat-desc">{stats.economy?.totalItems ?? 0} items in circulation</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-base-content/50">
|
|
||||||
Live data via WebSocket — updates every 5 seconds
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return <DashboardContent data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContent({ data }: { data: DashboardStats }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Maintenance banner */}
|
||||||
|
{data.maintenanceMode && (
|
||||||
|
<div className="flex items-center gap-3 bg-warning/10 border border-warning/30 rounded-lg px-5 py-3">
|
||||||
|
<Wrench className="w-4 h-4 text-warning shrink-0" />
|
||||||
|
<span className="text-sm text-warning font-medium">
|
||||||
|
Maintenance mode is active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Total Users"
|
||||||
|
value={formatNumber(data.users.total)}
|
||||||
|
sub={`${formatNumber(data.users.active)} active`}
|
||||||
|
accent="border-info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Coins}
|
||||||
|
label="Total Wealth"
|
||||||
|
value={formatNumber(data.economy.totalWealth)}
|
||||||
|
accent="border-gold"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="Avg Level"
|
||||||
|
value={data.economy.avgLevel.toFixed(1)}
|
||||||
|
sub={`Top streak: ${data.economy.topStreak}`}
|
||||||
|
accent="border-success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Gift}
|
||||||
|
label="Active Lootdrops"
|
||||||
|
value={String(data.activeLootdrops?.length ?? 0)}
|
||||||
|
accent="border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leaderboards */}
|
||||||
|
{data.leaderboards && (
|
||||||
|
<section>
|
||||||
|
<h2 className="font-display text-lg font-semibold mb-4">Leaderboards</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<LeaderboardColumn
|
||||||
|
title="Top Levels"
|
||||||
|
icon={Trophy}
|
||||||
|
entries={data.leaderboards.topLevels}
|
||||||
|
valueKey="level"
|
||||||
|
valuePrefix="Lv. "
|
||||||
|
/>
|
||||||
|
<LeaderboardColumn
|
||||||
|
title="Top Wealth"
|
||||||
|
icon={Crown}
|
||||||
|
entries={data.leaderboards.topWealth}
|
||||||
|
valueKey="balance"
|
||||||
|
/>
|
||||||
|
<LeaderboardColumn
|
||||||
|
title="Top Net Worth"
|
||||||
|
icon={Gem}
|
||||||
|
entries={data.leaderboards.topNetWorth}
|
||||||
|
valueKey="netWorth"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Events */}
|
||||||
|
{data.recentEvents.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="font-display text-lg font-semibold mb-4">Recent Events</h2>
|
||||||
|
<div className="bg-card rounded-lg border border-border divide-y divide-border">
|
||||||
|
{data.recentEvents.slice(0, 20).map((event, i) => {
|
||||||
|
const Icon = eventIcons[event.type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-3 px-5 py-3 hover:bg-raised/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn("w-4 h-4 mt-0.5 shrink-0", eventColors[event.type])}
|
||||||
|
/>
|
||||||
|
<span className="text-sm flex-1">
|
||||||
|
{event.icon && <span className="mr-1.5">{event.icon}</span>}
|
||||||
|
{event.message}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono whitespace-nowrap">
|
||||||
|
{timeAgo(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bot status footer */}
|
||||||
|
<footer className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-text-tertiary border-t border-border pt-6">
|
||||||
|
<span className="font-medium text-text-secondary">{data.bot.name}</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Wifi className="w-3 h-3" />
|
||||||
|
{Math.round(data.ping.avg)}ms
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatUptime(data.uptime)}
|
||||||
|
</span>
|
||||||
|
{data.bot.status && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-success" />
|
||||||
|
{data.bot.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { get, post, put, del } from "../lib/api";
|
|
||||||
import DataTable, { type Column } from "../components/DataTable";
|
|
||||||
import Modal from "../components/Modal";
|
|
||||||
|
|
||||||
interface Item {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
type: string;
|
|
||||||
rarity: string;
|
|
||||||
price: string | null;
|
|
||||||
iconUrl: string;
|
|
||||||
imageUrl: string;
|
|
||||||
usageData: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ItemsResponse {
|
|
||||||
items: Item[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ITEM_TYPES = ["CONSUMABLE", "EQUIPMENT", "MATERIAL", "LOOTBOX", "COLLECTIBLE", "KEY", "TOOL"];
|
|
||||||
const ITEM_RARITIES = ["C", "R", "SR", "SSR"];
|
|
||||||
|
|
||||||
const emptyForm = () => ({
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
type: "MATERIAL",
|
|
||||||
rarity: "C",
|
|
||||||
price: "",
|
|
||||||
iconUrl: "",
|
|
||||||
imageUrl: "",
|
|
||||||
usageData: null as unknown,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Items() {
|
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [typeFilter, setTypeFilter] = useState("");
|
|
||||||
const [rarityFilter, setRarityFilter] = useState("");
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const limit = 25;
|
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<Item | null>(null);
|
|
||||||
const [form, setForm] = useState(emptyForm());
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchItems = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
|
|
||||||
if (search) params.set("search", search);
|
|
||||||
if (typeFilter) params.set("type", typeFilter);
|
|
||||||
if (rarityFilter) params.set("rarity", rarityFilter);
|
|
||||||
|
|
||||||
get<ItemsResponse>(`/api/items?${params}`)
|
|
||||||
.then((data) => {
|
|
||||||
setItems(data.items);
|
|
||||||
setTotal(data.total);
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [search, typeFilter, rarityFilter, page]);
|
|
||||||
|
|
||||||
useEffect(() => { fetchItems(); }, [fetchItems]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
setForm(emptyForm());
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (item: Item) => {
|
|
||||||
setEditing(item);
|
|
||||||
setForm({
|
|
||||||
name: item.name,
|
|
||||||
description: item.description ?? "",
|
|
||||||
type: item.type,
|
|
||||||
rarity: item.rarity,
|
|
||||||
price: item.price ?? "",
|
|
||||||
iconUrl: item.iconUrl,
|
|
||||||
imageUrl: item.imageUrl,
|
|
||||||
usageData: item.usageData,
|
|
||||||
});
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: form.name,
|
|
||||||
description: form.description || null,
|
|
||||||
type: form.type,
|
|
||||||
rarity: form.rarity,
|
|
||||||
price: form.price || null,
|
|
||||||
iconUrl: form.iconUrl,
|
|
||||||
imageUrl: form.imageUrl,
|
|
||||||
usageData: form.usageData,
|
|
||||||
};
|
|
||||||
if (editing) {
|
|
||||||
await put(`/api/items/${editing.id}`, payload);
|
|
||||||
} else {
|
|
||||||
await post("/api/items", payload);
|
|
||||||
}
|
|
||||||
setModalOpen(false);
|
|
||||||
fetchItems();
|
|
||||||
} catch (e) {
|
|
||||||
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (item: Item) => {
|
|
||||||
if (!confirm(`Delete "${item.name}"?`)) return;
|
|
||||||
await del(`/api/items/${item.id}`);
|
|
||||||
fetchItems();
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: Column<Item>[] = [
|
|
||||||
{ key: "id", header: "ID", className: "w-16" },
|
|
||||||
{
|
|
||||||
key: "iconUrl",
|
|
||||||
header: "",
|
|
||||||
className: "w-12",
|
|
||||||
render: (row) =>
|
|
||||||
row.iconUrl ? (
|
|
||||||
<img src={row.iconUrl} className="w-8 h-8 rounded object-cover" alt="" />
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 bg-base-300 rounded" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: "name", header: "Name" },
|
|
||||||
{
|
|
||||||
key: "type",
|
|
||||||
header: "Type",
|
|
||||||
render: (row) => <span className="badge badge-sm badge-outline">{row.type}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rarity",
|
|
||||||
header: "Rarity",
|
|
||||||
render: (row) => {
|
|
||||||
const colors: Record<string, string> = { C: "badge-ghost", R: "badge-info", SR: "badge-warning", SSR: "badge-error" };
|
|
||||||
return <span className={`badge badge-sm ${colors[row.rarity] ?? ""}`}>{row.rarity}</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ key: "price", header: "Price", render: (row) => row.price ? `${BigInt(row.price).toLocaleString()}` : "—" },
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "",
|
|
||||||
className: "w-24",
|
|
||||||
render: (row) => (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
|
|
||||||
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h1 className="text-2xl font-bold">Items</h1>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Item</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search..."
|
|
||||||
className="input input-bordered input-sm w-48"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
||||||
/>
|
|
||||||
<select className="select select-bordered select-sm" value={typeFilter} onChange={(e) => { setTypeFilter(e.target.value); setPage(0); }}>
|
|
||||||
<option value="">All Types</option>
|
|
||||||
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
<select className="select select-bordered select-sm" value={rarityFilter} onChange={(e) => { setRarityFilter(e.target.value); setPage(0); }}>
|
|
||||||
<option value="">All Rarities</option>
|
|
||||||
{ITEM_RARITIES.map((r) => <option key={r} value={r}>{r}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable columns={columns} data={items as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex justify-center mt-4">
|
|
||||||
<div className="join">
|
|
||||||
<button className="join-item btn btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>«</button>
|
|
||||||
<button className="join-item btn btn-sm">Page {page + 1} / {totalPages}</button>
|
|
||||||
<button className="join-item btn btn-sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>»</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={modalOpen}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
title={editing ? `Edit: ${editing.name}` : "New Item"}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="form-control col-span-2">
|
|
||||||
<label className="label"><span className="label-text">Name</span></label>
|
|
||||||
<input className="input input-bordered input-sm" maxLength={100} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control col-span-2">
|
|
||||||
<label className="label"><span className="label-text">Description</span></label>
|
|
||||||
<textarea className="textarea textarea-bordered textarea-sm" maxLength={500} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Type</span></label>
|
|
||||||
<select className="select select-bordered select-sm" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
|
||||||
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Rarity</span></label>
|
|
||||||
<select className="select select-bordered select-sm" value={form.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
|
|
||||||
{ITEM_RARITIES.map((r) => <option key={r} value={r}>{r}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-control col-span-2">
|
|
||||||
<label className="label"><span className="label-text">Price</span></label>
|
|
||||||
<input className="input input-bordered input-sm" placeholder="Leave empty for no price" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Icon URL</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={form.iconUrl} onChange={(e) => setForm({ ...form, iconUrl: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Image URL</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control col-span-2">
|
|
||||||
<label className="label"><span className="label-text">Usage Data (JSON)</span></label>
|
|
||||||
<textarea
|
|
||||||
className="textarea textarea-bordered textarea-sm font-mono text-xs"
|
|
||||||
rows={4}
|
|
||||||
value={form.usageData ? JSON.stringify(form.usageData, null, 2) : "{}"}
|
|
||||||
onChange={(e) => {
|
|
||||||
try { setForm({ ...form, usageData: e.target.value ? JSON.parse(e.target.value) : null }); } catch {}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { get, post, del } from "../lib/api";
|
|
||||||
import DataTable, { type Column } from "../components/DataTable";
|
|
||||||
import Modal from "../components/Modal";
|
|
||||||
|
|
||||||
interface Lootdrop {
|
|
||||||
messageId: string;
|
|
||||||
channelId: string;
|
|
||||||
rewardAmount: number;
|
|
||||||
currency: string;
|
|
||||||
claimedBy: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
expiresAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LootdropsResponse {
|
|
||||||
lootdrops: Lootdrop[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Lootdrops() {
|
|
||||||
const [drops, setDrops] = useState<Lootdrop[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [spawnOpen, setSpawnOpen] = useState(false);
|
|
||||||
const [spawnForm, setSpawnForm] = useState({ channelId: "", amount: "", currency: "" });
|
|
||||||
const [spawning, setSpawning] = useState(false);
|
|
||||||
|
|
||||||
const fetchDrops = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
get<LootdropsResponse>("/api/lootdrops")
|
|
||||||
.then((data) => setDrops(data.lootdrops))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchDrops(); }, [fetchDrops]);
|
|
||||||
|
|
||||||
const handleSpawn = async () => {
|
|
||||||
if (!spawnForm.channelId) return;
|
|
||||||
setSpawning(true);
|
|
||||||
try {
|
|
||||||
const payload: Record<string, unknown> = { channelId: spawnForm.channelId };
|
|
||||||
if (spawnForm.amount) payload.amount = Number(spawnForm.amount);
|
|
||||||
if (spawnForm.currency) payload.currency = spawnForm.currency;
|
|
||||||
await post("/api/lootdrops", payload);
|
|
||||||
setSpawnOpen(false);
|
|
||||||
setSpawnForm({ channelId: "", amount: "", currency: "" });
|
|
||||||
fetchDrops();
|
|
||||||
} catch (e) {
|
|
||||||
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to spawn");
|
|
||||||
} finally {
|
|
||||||
setSpawning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = async (drop: Lootdrop) => {
|
|
||||||
if (!confirm("Cancel this lootdrop?")) return;
|
|
||||||
await del(`/api/lootdrops/${drop.messageId}`);
|
|
||||||
fetchDrops();
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: Column<Lootdrop>[] = [
|
|
||||||
{ key: "messageId", header: "Message ID" },
|
|
||||||
{ key: "channelId", header: "Channel" },
|
|
||||||
{ key: "rewardAmount", header: "Reward", render: (r) => `${r.rewardAmount} ${r.currency}` },
|
|
||||||
{
|
|
||||||
key: "claimedBy",
|
|
||||||
header: "Status",
|
|
||||||
render: (r) => r.claimedBy
|
|
||||||
? <span className="badge badge-sm badge-ghost">Claimed by {r.claimedBy}</span>
|
|
||||||
: <span className="badge badge-sm badge-success">Active</span>,
|
|
||||||
},
|
|
||||||
{ key: "createdAt", header: "Created", render: (r) => new Date(r.createdAt).toLocaleString() },
|
|
||||||
{
|
|
||||||
key: "expiresAt",
|
|
||||||
header: "Expires",
|
|
||||||
render: (r) => r.expiresAt ? new Date(r.expiresAt).toLocaleString() : "—",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "",
|
|
||||||
className: "w-20",
|
|
||||||
render: (row) =>
|
|
||||||
!row.claimedBy ? (
|
|
||||||
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleCancel(row); }}>Cancel</button>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h1 className="text-2xl font-bold">Lootdrops</h1>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => setSpawnOpen(true)}>Spawn Lootdrop</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable columns={columns} data={drops as unknown as Record<string, unknown>[]} keyField="messageId" loading={loading} />
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={spawnOpen}
|
|
||||||
onClose={() => setSpawnOpen(false)}
|
|
||||||
title="Spawn Lootdrop"
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button className="btn btn-ghost" onClick={() => setSpawnOpen(false)}>Cancel</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleSpawn} disabled={spawning}>
|
|
||||||
{spawning ? <span className="loading loading-spinner loading-sm" /> : "Spawn"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Channel ID</span></label>
|
|
||||||
<input
|
|
||||||
className="input input-bordered input-sm"
|
|
||||||
placeholder="Discord channel ID"
|
|
||||||
value={spawnForm.channelId}
|
|
||||||
onChange={(e) => setSpawnForm({ ...spawnForm, channelId: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Amount (optional)</span></label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input input-bordered input-sm"
|
|
||||||
placeholder="Random if empty"
|
|
||||||
value={spawnForm.amount}
|
|
||||||
onChange={(e) => setSpawnForm({ ...spawnForm, amount: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Currency (optional)</span></label>
|
|
||||||
<input
|
|
||||||
className="input input-bordered input-sm"
|
|
||||||
placeholder="Default from settings"
|
|
||||||
value={spawnForm.currency}
|
|
||||||
onChange={(e) => setSpawnForm({ ...spawnForm, currency: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
17
panel/src/pages/PlaceholderPage.tsx
Normal file
17
panel/src/pages/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Construction } from "lucide-react";
|
||||||
|
|
||||||
|
export default function PlaceholderPage({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32 text-center">
|
||||||
|
<Construction className="w-10 h-10 text-text-tertiary mb-4" />
|
||||||
|
<h1 className="font-display text-2xl font-bold mb-2">{title}</h1>
|
||||||
|
<p className="text-sm text-text-tertiary max-w-md">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { get, post, put, del } from "../lib/api";
|
|
||||||
import DataTable, { type Column } from "../components/DataTable";
|
|
||||||
import Modal from "../components/Modal";
|
|
||||||
|
|
||||||
interface Quest {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
triggerEvent: string;
|
|
||||||
requirements: { target: number };
|
|
||||||
rewards: { xp: number; balance: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuestsResponse {
|
|
||||||
success: boolean;
|
|
||||||
data: Quest[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Quests() {
|
|
||||||
const [quests, setQuests] = useState<Quest[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<Quest | null>(null);
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
triggerEvent: "",
|
|
||||||
target: 1,
|
|
||||||
xpReward: 0,
|
|
||||||
balanceReward: 0,
|
|
||||||
});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchQuests = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
get<QuestsResponse>("/api/quests")
|
|
||||||
.then((data) => setQuests(data.data))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchQuests(); }, [fetchQuests]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
setForm({ name: "", description: "", triggerEvent: "", target: 1, xpReward: 0, balanceReward: 0 });
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (quest: Quest) => {
|
|
||||||
setEditing(quest);
|
|
||||||
setForm({
|
|
||||||
name: quest.name,
|
|
||||||
description: quest.description ?? "",
|
|
||||||
triggerEvent: quest.triggerEvent,
|
|
||||||
target: quest.requirements.target,
|
|
||||||
xpReward: quest.rewards.xp,
|
|
||||||
balanceReward: quest.rewards.balance,
|
|
||||||
});
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: form.name,
|
|
||||||
description: form.description || undefined,
|
|
||||||
triggerEvent: form.triggerEvent,
|
|
||||||
target: form.target,
|
|
||||||
xpReward: form.xpReward,
|
|
||||||
balanceReward: form.balanceReward,
|
|
||||||
};
|
|
||||||
if (editing) {
|
|
||||||
await put(`/api/quests/${editing.id}`, payload);
|
|
||||||
} else {
|
|
||||||
await post("/api/quests", payload);
|
|
||||||
}
|
|
||||||
setModalOpen(false);
|
|
||||||
fetchQuests();
|
|
||||||
} catch (e) {
|
|
||||||
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (quest: Quest) => {
|
|
||||||
if (!confirm(`Delete "${quest.name}"?`)) return;
|
|
||||||
await del(`/api/quests/${quest.id}`);
|
|
||||||
fetchQuests();
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: Column<Quest>[] = [
|
|
||||||
{ key: "id", header: "ID", className: "w-16" },
|
|
||||||
{ key: "name", header: "Name" },
|
|
||||||
{ key: "triggerEvent", header: "Trigger", render: (r) => <span className="badge badge-sm badge-outline">{r.triggerEvent}</span> },
|
|
||||||
{ key: "target", header: "Target", render: (r) => String(r.requirements.target) },
|
|
||||||
{ key: "xpReward", header: "XP Reward", render: (r) => String(r.rewards.xp) },
|
|
||||||
{ key: "balanceReward", header: "Gold Reward", render: (r) => String(r.rewards.balance) },
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "",
|
|
||||||
className: "w-24",
|
|
||||||
render: (row) => (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
|
|
||||||
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h1 className="text-2xl font-bold">Quests</h1>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Quest</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable columns={columns} data={quests as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={modalOpen}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
title={editing ? `Edit: ${editing.name}` : "New Quest"}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Name</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Description</span></label>
|
|
||||||
<textarea className="textarea textarea-bordered textarea-sm" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Trigger Event</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={form.triggerEvent} onChange={(e) => setForm({ ...form, triggerEvent: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Target</span></label>
|
|
||||||
<input type="number" className="input input-bordered input-sm" min={1} value={form.target} onChange={(e) => setForm({ ...form, target: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">XP Reward</span></label>
|
|
||||||
<input type="number" className="input input-bordered input-sm" min={0} value={form.xpReward} onChange={(e) => setForm({ ...form, xpReward: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Balance Reward</span></label>
|
|
||||||
<input type="number" className="input input-bordered input-sm" min={0} value={form.balanceReward} onChange={(e) => setForm({ ...form, balanceReward: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { get, post } from "../lib/api";
|
|
||||||
|
|
||||||
export default function Settings() {
|
|
||||||
const [settings, setSettings] = useState<Record<string, unknown> | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [raw, setRaw] = useState("");
|
|
||||||
const [parseError, setParseError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
get<Record<string, unknown>>("/api/settings")
|
|
||||||
.then((data) => {
|
|
||||||
setSettings(data);
|
|
||||||
setRaw(JSON.stringify(data, null, 2));
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRawChange = (value: string) => {
|
|
||||||
setRaw(value);
|
|
||||||
try {
|
|
||||||
JSON.parse(value);
|
|
||||||
setParseError("");
|
|
||||||
} catch (e) {
|
|
||||||
setParseError(e instanceof Error ? e.message : "Invalid JSON");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (parseError) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
const updated = await post<Record<string, unknown>>("/api/settings", parsed);
|
|
||||||
setSettings(updated);
|
|
||||||
setRaw(JSON.stringify(updated, null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center p-12">
|
|
||||||
<span className="loading loading-spinner loading-lg" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !!parseError}
|
|
||||||
>
|
|
||||||
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-base-content/60 mb-2">
|
|
||||||
Edit game configuration directly. Changes are merged with existing settings.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{parseError && (
|
|
||||||
<div className="alert alert-error mb-3 py-2 text-sm">{parseError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
className="textarea textarea-bordered w-full font-mono text-sm"
|
|
||||||
rows={30}
|
|
||||||
value={raw}
|
|
||||||
onChange={(e) => handleRawChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<h3 className="text-sm font-semibold mb-2">Quick Reference — Top-level keys:</h3>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{Object.keys(settings).map((key) => (
|
|
||||||
<span key={key} className="badge badge-sm badge-outline">{key}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { get, put, post, del } from "../lib/api";
|
|
||||||
import DataTable, { type Column } from "../components/DataTable";
|
|
||||||
import Modal from "../components/Modal";
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
classId: string | null;
|
|
||||||
username: string;
|
|
||||||
isActive: boolean;
|
|
||||||
balance: string;
|
|
||||||
xp: string;
|
|
||||||
level: number;
|
|
||||||
dailyStreak: number;
|
|
||||||
settings: unknown;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsersResponse {
|
|
||||||
users: User[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InventoryEntry {
|
|
||||||
userId: string;
|
|
||||||
itemId: number;
|
|
||||||
quantity: string;
|
|
||||||
item: { id: number; name: string; rarity: string; type: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InventoryResponse {
|
|
||||||
inventory: InventoryEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Users() {
|
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const limit = 25;
|
|
||||||
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
|
||||||
const [inventory, setInventory] = useState<InventoryEntry[]>([]);
|
|
||||||
const [invLoading, setInvLoading] = useState(false);
|
|
||||||
const [editForm, setEditForm] = useState<{ balance: string; level: string; xp: string; dailyStreak: string; isActive: boolean }>({ balance: "0", level: "1", xp: "0", dailyStreak: "0", isActive: true });
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const [addItemOpen, setAddItemOpen] = useState(false);
|
|
||||||
const [addItemId, setAddItemId] = useState("");
|
|
||||||
const [addItemQty, setAddItemQty] = useState("1");
|
|
||||||
|
|
||||||
const fetchUsers = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
|
|
||||||
if (search) params.set("search", search);
|
|
||||||
|
|
||||||
get<UsersResponse>(`/api/users?${params}`)
|
|
||||||
.then((data) => { setUsers(data.users); setTotal(data.total); })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [search, page]);
|
|
||||||
|
|
||||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
|
||||||
|
|
||||||
const openUser = (user: User) => {
|
|
||||||
setSelectedUser(user);
|
|
||||||
setEditForm({
|
|
||||||
balance: user.balance,
|
|
||||||
level: String(user.level),
|
|
||||||
xp: user.xp,
|
|
||||||
dailyStreak: String(user.dailyStreak),
|
|
||||||
isActive: user.isActive,
|
|
||||||
});
|
|
||||||
setInvLoading(true);
|
|
||||||
get<InventoryResponse>(`/api/users/${user.id}/inventory`)
|
|
||||||
.then((data) => setInventory(data.inventory))
|
|
||||||
.catch(() => setInventory([]))
|
|
||||||
.finally(() => setInvLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveUser = async () => {
|
|
||||||
if (!selectedUser) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await put(`/api/users/${selectedUser.id}`, {
|
|
||||||
balance: editForm.balance,
|
|
||||||
level: Number(editForm.level),
|
|
||||||
xp: editForm.xp,
|
|
||||||
dailyStreak: Number(editForm.dailyStreak),
|
|
||||||
isActive: editForm.isActive,
|
|
||||||
});
|
|
||||||
fetchUsers();
|
|
||||||
setSelectedUser(null);
|
|
||||||
} catch (e) {
|
|
||||||
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddItem = async () => {
|
|
||||||
if (!selectedUser || !addItemId) return;
|
|
||||||
try {
|
|
||||||
await post(`/api/users/${selectedUser.id}/inventory`, { itemId: Number(addItemId), quantity: addItemQty });
|
|
||||||
const data = await get<InventoryResponse>(`/api/users/${selectedUser.id}/inventory`);
|
|
||||||
setInventory(data.inventory);
|
|
||||||
setAddItemOpen(false);
|
|
||||||
setAddItemId("");
|
|
||||||
setAddItemQty("1");
|
|
||||||
} catch (e) {
|
|
||||||
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveItem = async (itemId: number) => {
|
|
||||||
if (!selectedUser) return;
|
|
||||||
await del(`/api/users/${selectedUser.id}/inventory/${itemId}`);
|
|
||||||
const data = await get<InventoryResponse>(`/api/users/${selectedUser.id}/inventory`);
|
|
||||||
setInventory(data.inventory);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: Column<User>[] = [
|
|
||||||
{ key: "id", header: "ID" },
|
|
||||||
{ key: "username", header: "Username" },
|
|
||||||
{ key: "level", header: "Lv" },
|
|
||||||
{ key: "xp", header: "XP", render: (r) => BigInt(r.xp).toLocaleString() },
|
|
||||||
{ key: "balance", header: "Balance", render: (r) => BigInt(r.balance).toLocaleString() },
|
|
||||||
{ key: "dailyStreak", header: "Streak" },
|
|
||||||
{
|
|
||||||
key: "isActive",
|
|
||||||
header: "Active",
|
|
||||||
render: (r) => <span className={`badge badge-sm ${r.isActive ? "badge-success" : "badge-ghost"}`}>{r.isActive ? "Yes" : "No"}</span>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold mb-4">Users</h1>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by username or ID..."
|
|
||||||
className="input input-bordered input-sm w-72"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable columns={columns} data={users as unknown as Record<string, unknown>[]} keyField="id" loading={loading} onRowClick={(r) => openUser(r as unknown as User)} />
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex justify-center mt-4">
|
|
||||||
<div className="join">
|
|
||||||
<button className="join-item btn btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>«</button>
|
|
||||||
<button className="join-item btn btn-sm">Page {page + 1} / {totalPages}</button>
|
|
||||||
<button className="join-item btn btn-sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>»</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={!!selectedUser}
|
|
||||||
onClose={() => setSelectedUser(null)}
|
|
||||||
title={selectedUser ? `User: ${selectedUser.username}` : ""}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button className="btn btn-ghost" onClick={() => setSelectedUser(null)}>Close</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleSaveUser} disabled={saving}>
|
|
||||||
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save Changes"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedUser && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="text-sm text-base-content/60">
|
|
||||||
ID: {selectedUser.id} | Class: {selectedUser.classId ?? "None"} | Joined: {new Date(selectedUser.createdAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Balance</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={editForm.balance} onChange={(e) => setEditForm({ ...editForm, balance: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Level</span></label>
|
|
||||||
<input type="number" className="input input-bordered input-sm" min={0} value={editForm.level} onChange={(e) => setEditForm({ ...editForm, level: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">XP</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={editForm.xp} onChange={(e) => setEditForm({ ...editForm, xp: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Daily Streak</span></label>
|
|
||||||
<input type="number" className="input input-bordered input-sm" min={0} value={editForm.dailyStreak} onChange={(e) => setEditForm({ ...editForm, dailyStreak: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label cursor-pointer justify-start gap-2">
|
|
||||||
<input type="checkbox" className="toggle toggle-sm toggle-success" checked={editForm.isActive} onChange={(e) => setEditForm({ ...editForm, isActive: e.target.checked })} />
|
|
||||||
<span className="label-text">Active</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divider">Inventory</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm font-medium">Items ({inventory.length})</span>
|
|
||||||
<button className="btn btn-sm btn-outline" onClick={() => setAddItemOpen(true)}>+ Add Item</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{invLoading ? (
|
|
||||||
<span className="loading loading-spinner loading-sm" />
|
|
||||||
) : inventory.length === 0 ? (
|
|
||||||
<div className="text-sm text-base-content/50">No items</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto max-h-48">
|
|
||||||
<table className="table table-xs">
|
|
||||||
<thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{inventory.map((inv) => (
|
|
||||||
<tr key={inv.itemId}>
|
|
||||||
<td>{inv.item?.name ?? `#${inv.itemId}`}</td>
|
|
||||||
<td>{BigInt(inv.quantity).toLocaleString()}</td>
|
|
||||||
<td><button className="btn btn-ghost btn-xs text-error" onClick={() => handleRemoveItem(inv.itemId)}>Remove</button></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={addItemOpen}
|
|
||||||
onClose={() => setAddItemOpen(false)}
|
|
||||||
title="Add Item to Inventory"
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button className="btn btn-ghost" onClick={() => setAddItemOpen(false)}>Cancel</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleAddItem}>Add</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Item ID</span></label>
|
|
||||||
<input type="number" className="input input-bordered input-sm" value={addItemId} onChange={(e) => setAddItemId(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label"><span className="label-text">Quantity</span></label>
|
|
||||||
<input className="input input-bordered input-sm" value={addItemQty} onChange={(e) => setAddItemQty(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,11 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user