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",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"react": "^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": {
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
@@ -34,7 +38,6 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"daisyui": "^5.0.43",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -309,14 +312,14 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"react": "^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": {
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"daisyui": "^5.0.43",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"typescript": "^5.9.3",
|
||||
"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 Layout from "./components/Layout";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Layout, { type Page } from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Items from "./pages/Items";
|
||||
import Quests from "./pages/Quests";
|
||||
import Classes from "./pages/Classes";
|
||||
import Users from "./pages/Users";
|
||||
import Settings from "./pages/Settings";
|
||||
import Lootdrops from "./pages/Lootdrops";
|
||||
import PlaceholderPage from "./pages/PlaceholderPage";
|
||||
|
||||
const placeholders: Record<string, { title: string; description: string }> = {
|
||||
users: {
|
||||
title: "Users",
|
||||
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() {
|
||||
const { loading, user, logout } = useAuth();
|
||||
const [page, setPage] = useState<Page>("dashboard");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<span className="loading loading-spinner loading-lg" />
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div className="card bg-base-100 shadow-xl p-8 text-center max-w-sm">
|
||||
<h1 className="text-2xl font-bold mb-2">Aurora Admin Panel</h1>
|
||||
<p className="text-base-content/60 mb-6">Sign in with Discord to continue.</p>
|
||||
<a href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`} className="btn btn-primary">
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-xs">
|
||||
<div className="font-display font-bold text-3xl tracking-tight mb-1">Aurora</div>
|
||||
<p className="text-sm text-muted-foreground mb-8">Admin Panel</p>
|
||||
<a
|
||||
href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`}
|
||||
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"
|
||||
>
|
||||
Sign in with Discord
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground/40 mt-6">Authorized administrators only</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout user={user} onLogout={logout} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="items" element={<Items />} />
|
||||
<Route path="quests" element={<Quests />} />
|
||||
<Route path="classes" element={<Classes />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="lootdrops" element={<Lootdrops />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
|
||||
{page === "dashboard" ? (
|
||||
<Dashboard />
|
||||
) : (
|
||||
<PlaceholderPage {...placeholders[page]!} />
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ 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" },
|
||||
{ to: "/items", label: "Items", icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
];
|
||||
export type Page =
|
||||
| "dashboard"
|
||||
| "users"
|
||||
| "items"
|
||||
| "classes"
|
||||
| "quests"
|
||||
| "lootdrops"
|
||||
| "moderation"
|
||||
| "transactions"
|
||||
| "settings";
|
||||
|
||||
function avatarUrl(user: AuthUser): string {
|
||||
if (user.avatar) {
|
||||
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`;
|
||||
}
|
||||
const index = (BigInt(user.discordId) >> 22n) % 6n;
|
||||
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||
}
|
||||
const navItems: { page: Page; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ page: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ page: "users", label: "Users", icon: Users },
|
||||
{ page: "items", label: "Items", icon: Package },
|
||||
{ page: "classes", label: "Classes", icon: GraduationCap },
|
||||
{ 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({
|
||||
user,
|
||||
onLogout,
|
||||
logout,
|
||||
currentPage,
|
||||
onNavigate,
|
||||
children,
|
||||
}: {
|
||||
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 (
|
||||
<div className="flex h-screen bg-base-200">
|
||||
<div className="min-h-screen flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-base-300 flex flex-col">
|
||||
<div className="p-4 font-bold text-xl border-b border-base-content/10">
|
||||
Aurora Panel
|
||||
<aside
|
||||
className={cn(
|
||||
"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>
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-primary text-primary-content"
|
||||
: "hover:bg-base-content/10"
|
||||
}`
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
|
||||
{navItems.map(({ page, label, icon: Icon }) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onNavigate(page)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
currentPage === page
|
||||
? "bg-primary/15 text-primary border-l-4 border-primary"
|
||||
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
|
||||
</svg>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
<Icon className={cn("w-5 h-5 shrink-0", currentPage === page && "text-primary")} />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-3 border-t border-base-content/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={avatarUrl(user)}
|
||||
className="w-8 h-8 rounded-full"
|
||||
alt=""
|
||||
/>
|
||||
<span className="text-sm font-medium flex-1 truncate">
|
||||
{user.username}
|
||||
</span>
|
||||
|
||||
{/* User & collapse */}
|
||||
<div className="border-t border-border p-3 space-y-2">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
|
||||
{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
|
||||
onClick={onLogout}
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Logout"
|
||||
onClick={logout}
|
||||
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="Sign out"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{!collapsed && <span>Sign out</span>}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}>
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</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";
|
||||
@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 { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</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 { get } from "../lib/api";
|
||||
import {
|
||||
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 {
|
||||
bot: { name: string; avatarUrl: string | null; status: string | null };
|
||||
guilds: { count: number };
|
||||
users: { total: number; active: number };
|
||||
economy: { totalWealth: string; avgLevel: number; topStreak: number; totalItems: number };
|
||||
commands: { total: number; active: number; disabled: number };
|
||||
ping: { avg: number };
|
||||
uptime: number;
|
||||
function formatNumber(n: number | string): string {
|
||||
const num = typeof n === "string" ? parseFloat(n) : n;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
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() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
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();
|
||||
}, []);
|
||||
const { data, loading, error } = useDashboard();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center p-12">
|
||||
<span className="loading loading-spinner loading-lg" />
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return <div className="alert alert-error">Failed to load stats</div>;
|
||||
|
||||
const uptimeHours = Math.floor((stats.uptime ?? 0) / 3600);
|
||||
const uptimeMins = Math.floor(((stats.uptime ?? 0) % 3600) / 60);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<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 className="flex items-center justify-center py-32">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-3" />
|
||||
<p className="text-sm text-text-tertiary">{error}</p>
|
||||
</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,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user