From 0d923491b5de8cceb4f8ec17a99f0bcf293b6cd2 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 9 Jan 2026 19:28:14 +0100 Subject: [PATCH] feat: (ui) settings drawers --- web/bun.lock | 24 +- web/package.json | 10 +- web/src/components/activity-chart.tsx | 164 ++++ web/src/components/commands-drawer.tsx | 230 ++++++ web/src/components/settings-drawer.tsx | 1022 ++++++++++++++++++++++++ web/src/components/stat-card.tsx | 34 +- web/src/components/ui/accordion.tsx | 66 ++ web/src/components/ui/form.tsx | 165 ++++ web/src/components/ui/input.tsx | 2 +- web/src/components/ui/label.tsx | 22 + web/src/components/ui/scroll-area.tsx | 58 ++ web/src/components/ui/select.tsx | 6 +- web/src/components/ui/tabs.tsx | 64 ++ web/src/components/ui/textarea.tsx | 18 + web/src/pages/Dashboard.tsx | 27 +- web/src/pages/DesignSystem.tsx | 32 +- web/styles/globals.css | 63 +- 17 files changed, 1977 insertions(+), 30 deletions(-) create mode 100644 web/src/components/activity-chart.tsx create mode 100644 web/src/components/commands-drawer.tsx create mode 100644 web/src/components/settings-drawer.tsx create mode 100644 web/src/components/ui/accordion.tsx create mode 100644 web/src/components/ui/form.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/components/ui/scroll-area.tsx create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/components/ui/textarea.tsx diff --git a/web/bun.lock b/web/bun.lock index 602bf75..773abef 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,12 +5,16 @@ "": { "name": "bun-react-template", "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", @@ -18,10 +22,12 @@ "lucide-react": "^0.562.0", "react": "^19", "react-dom": "^19", + "react-hook-form": "^7.70.0", "react-router-dom": "^7.12.0", "recharts": "^3.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "zod": "^4.3.5", }, "devDependencies": { "@types/bun": "latest", @@ -41,6 +47,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg=="], @@ -67,8 +75,12 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -97,6 +109,10 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], @@ -105,6 +121,8 @@ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -219,6 +237,8 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="], + "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], @@ -267,6 +287,8 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/web/package.json b/web/package.json index 2cada7a..5a9e149 100644 --- a/web/package.json +++ b/web/package.json @@ -9,12 +9,16 @@ "build": "bun run build.ts" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", @@ -22,10 +26,12 @@ "lucide-react": "^0.562.0", "react": "^19", "react-dom": "^19", + "react-hook-form": "^7.70.0", "react-router-dom": "^7.12.0", "recharts": "^3.6.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zod": "^4.3.5" }, "devDependencies": { "@types/react": "^19", diff --git a/web/src/components/activity-chart.tsx b/web/src/components/activity-chart.tsx new file mode 100644 index 0000000..9f87ad6 --- /dev/null +++ b/web/src/components/activity-chart.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from "react"; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; +import { Card, CardHeader, CardTitle, CardContent } from "./ui/card"; +import { Activity } from "lucide-react"; +import { cn } from "../lib/utils"; +import type { ActivityData } from "@shared/modules/dashboard/dashboard.types"; + +interface ActivityChartProps { + className?: string; + data?: ActivityData[]; +} + +export function ActivityChart({ className, data: providedData }: ActivityChartProps) { + const [data, setData] = useState([]); // using any[] for the displayTime extension + const [isLoading, setIsLoading] = useState(!providedData); + const [error, setError] = useState(null); + + useEffect(() => { + if (providedData) { + // Process provided data + const formatted = providedData.map((item) => ({ + ...item, + displayTime: new Date(item.hour).getHours().toString().padStart(2, '0') + ':00', + })); + setData(formatted); + return; + } + + let mounted = true; + + async function fetchActivity() { + try { + const response = await fetch("/api/stats/activity"); + if (!response.ok) throw new Error("Failed to fetch activity data"); + const result = await response.json(); + + if (mounted) { + // Normalize data: ensure we have 24 hours format + // The API returns { hour: ISOString, commands: number, transactions: number } + // We want to format hour to readable time + const formatted = result.map((item: ActivityData) => ({ + ...item, + displayTime: new Date(item.hour).getHours().toString().padStart(2, '0') + ':00', + })); + + // Sort by time just in case, though API should handle it + setData(formatted); + + // Only set loading to false on the first load to avoid flickering + setIsLoading(false); + } + } catch (err) { + if (mounted) { + console.error(err); + setError("Failed to load activity data"); + setIsLoading(false); + } + } + } + + fetchActivity(); + + // Refresh every 60 seconds + const interval = setInterval(fetchActivity, 60000); + + return () => { + mounted = false; + clearInterval(interval); + }; + }, [providedData]); + + if (error) { + return ( + + + {error} + + + ); + } + + return ( + + +
+ + 24h Activity +
+
+ +
+ {isLoading ? ( +
+
+
+ ) : ( + + + + + + + + + + + + + + + `${value}`} + /> + + + + + + )} +
+
+
+ ); +} diff --git a/web/src/components/commands-drawer.tsx b/web/src/components/commands-drawer.tsx new file mode 100644 index 0000000..ff4cc2e --- /dev/null +++ b/web/src/components/commands-drawer.tsx @@ -0,0 +1,230 @@ +import React, { useEffect, useState, useMemo } from "react"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet"; +import { ScrollArea } from "./ui/scroll-area"; +import { Switch } from "./ui/switch"; +import { Badge } from "./ui/badge"; +import { Loader2, Terminal, Sparkles, Coins, Shield, Backpack, TrendingUp, MessageSquare, User } from "lucide-react"; +import { cn } from "../lib/utils"; +import { toast } from "sonner"; + +interface Command { + name: string; + category: string; +} + +interface CommandsDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// Category metadata for visual styling +const CATEGORY_CONFIG: Record = { + admin: { label: "Admin", color: "bg-red-500/20 text-red-400 border-red-500/30", icon: Shield }, + economy: { label: "Economy", color: "bg-amber-500/20 text-amber-400 border-amber-500/30", icon: Coins }, + leveling: { label: "Leveling", color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", icon: TrendingUp }, + inventory: { label: "Inventory", color: "bg-blue-500/20 text-blue-400 border-blue-500/30", icon: Backpack }, + quest: { label: "Quests", color: "bg-purple-500/20 text-purple-400 border-purple-500/30", icon: Sparkles }, + feedback: { label: "Feedback", color: "bg-cyan-500/20 text-cyan-400 border-cyan-500/30", icon: MessageSquare }, + user: { label: "User", color: "bg-pink-500/20 text-pink-400 border-pink-500/30", icon: User }, + uncategorized: { label: "Other", color: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30", icon: Terminal }, +}; + +export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) { + const [commands, setCommands] = useState([]); + const [enabledState, setEnabledState] = useState>({}); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(null); + + // Fetch commands and their enabled state + useEffect(() => { + if (open) { + setLoading(true); + Promise.all([ + fetch("/api/settings/meta").then(res => res.json()), + fetch("/api/settings").then(res => res.json()), + ]).then(([meta, config]) => { + setCommands(meta.commands || []); + // Build enabled state from config.commands (undefined = enabled by default) + const state: Record = {}; + for (const cmd of meta.commands || []) { + state[cmd.name] = config.commands?.[cmd.name] !== false; + } + setEnabledState(state); + }).catch(err => { + toast.error("Failed to load commands"); + console.error(err); + }).finally(() => { + setLoading(false); + }); + } + }, [open]); + + // Group commands by category + const groupedCommands = useMemo(() => { + const groups: Record = {}; + for (const cmd of commands) { + const cat = cmd.category || "uncategorized"; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(cmd); + } + // Sort categories: admin first, then alphabetically + const sortedCategories = Object.keys(groups).sort((a, b) => { + if (a === "admin") return -1; + if (b === "admin") return 1; + return a.localeCompare(b); + }); + return sortedCategories.map(cat => ({ category: cat, commands: groups[cat]! })); + }, [commands]); + + // Toggle command enabled state + const toggleCommand = async (commandName: string, enabled: boolean) => { + setSaving(commandName); + try { + const response = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + commands: { + [commandName]: enabled, + }, + }), + }); + + if (!response.ok) throw new Error("Failed to save"); + + setEnabledState(prev => ({ ...prev, [commandName]: enabled })); + toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, { + duration: 2000, + id: "command-toggle", // Replace previous toast instead of stacking + }); + } catch (error) { + toast.error("Failed to toggle command"); + console.error(error); + } finally { + setSaving(null); + } + }; + + return ( + + + + + + Command Manager + + + Enable or disable commands. Changes take effect immediately. + + + + {loading ? ( +
+ +
+ ) : ( +
+ +
+ {groupedCommands.map(({ category, commands: cmds }) => { + const config = (CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.uncategorized)!; + const IconComponent = config.icon; + + return ( +
+ {/* Category Header */} +
+ +

+ {config.label} +

+ + {cmds.length} + +
+ + {/* Commands Grid */} +
+ {cmds.map(cmd => { + const isEnabled = enabledState[cmd.name] !== false; + const isSaving = saving === cmd.name; + + return ( +
+ {/* Category color accent bar */} +
+ +
+
+ {/* Icon with glow effect */} +
+ +
+ +
+ + /{cmd.name} + + + {category} + +
+
+ + toggleCommand(cmd.name, checked)} + disabled={isSaving} + className={cn( + "transition-opacity duration-300", + !isEnabled && "opacity-60" + )} + /> +
+
+ ); + })} +
+
+ ); + })} + + {groupedCommands.length === 0 && ( +
+ No commands found. +
+ )} +
+ +
+ )} + + + ); +} diff --git a/web/src/components/settings-drawer.tsx b/web/src/components/settings-drawer.tsx new file mode 100644 index 0000000..1ca6815 --- /dev/null +++ b/web/src/components/settings-drawer.tsx @@ -0,0 +1,1022 @@ +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetDescription, SheetFooter } from "./ui/sheet"; +import { Button } from "./ui/button"; +import { Settings, Save, Loader2, CreditCard, Terminal, MessageSquare, Shield, Users, Palette, Sparkles, AlertTriangle, Backpack } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; +import { ScrollArea } from "./ui/scroll-area"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "./ui/form"; +import { Input } from "./ui/input"; +import { Switch } from "./ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion"; +import { toast } from "sonner"; +import { Textarea } from "./ui/textarea"; +import { Badge } from "./ui/badge"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +// Sentinel value for "none" selection +const NONE_VALUE = "__none__"; + +// Schema definition matching backend config +const bigIntStringSchema = z.coerce.string() + .refine((val) => /^\d+$/.test(val), { message: "Must be a valid integer" }); + +const formSchema = z.object({ + leveling: z.object({ + base: z.number(), + exponent: z.number(), + chat: z.object({ + cooldownMs: z.number(), + minXp: z.number(), + maxXp: z.number(), + }) + }), + economy: z.object({ + daily: z.object({ + amount: bigIntStringSchema, + streakBonus: bigIntStringSchema, + weeklyBonus: bigIntStringSchema, + cooldownMs: z.number(), + }), + transfers: z.object({ + allowSelfTransfer: z.boolean(), + minAmount: bigIntStringSchema, + }), + exam: z.object({ + multMin: z.number(), + multMax: z.number(), + }) + }), + inventory: z.object({ + maxStackSize: bigIntStringSchema, + maxSlots: z.number(), + }), + commands: z.record(z.string(), z.boolean()).optional(), + lootdrop: z.object({ + activityWindowMs: z.number(), + minMessages: z.number(), + spawnChance: z.number(), + cooldownMs: z.number(), + reward: z.object({ + min: z.number(), + max: z.number(), + currency: z.string(), + }) + }), + studentRole: z.string().optional(), + visitorRole: z.string().optional(), + colorRoles: z.array(z.string()).default([]), + welcomeChannelId: z.string().optional(), + welcomeMessage: z.string().optional(), + feedbackChannelId: z.string().optional(), + terminal: z.object({ + channelId: z.string(), + messageId: z.string() + }).optional(), + moderation: z.object({ + prune: z.object({ + maxAmount: z.number(), + confirmThreshold: z.number(), + batchSize: z.number(), + batchDelayMs: z.number(), + }), + cases: z.object({ + dmOnWarn: z.boolean(), + logChannelId: z.string().optional(), + autoTimeoutThreshold: z.number().optional() + }) + }), + system: z.record(z.string(), z.any()).optional(), +}); + +type FormValues = z.infer; + +interface ConfigMeta { + roles: { id: string, name: string, color: string }[]; + channels: { id: string, name: string, type: number }[]; + commands: { name: string, category: string }[]; +} + +const toSelectValue = (v: string | undefined | null) => v || NONE_VALUE; +const fromSelectValue = (v: string) => v === NONE_VALUE ? "" : v; + +export function SettingsDrawer() { + const [open, setOpen] = useState(false); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema) as any, + defaultValues: { + economy: { + daily: { amount: "0", streakBonus: "0", weeklyBonus: "0", cooldownMs: 0 }, + transfers: { minAmount: "0", allowSelfTransfer: false }, + exam: { multMin: 1, multMax: 1 } + }, + leveling: { base: 100, exponent: 1.5, chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 } }, + inventory: { maxStackSize: "1", maxSlots: 10 }, + moderation: { + prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 }, + cases: { dmOnWarn: true } + }, + lootdrop: { + spawnChance: 0.05, + minMessages: 10, + cooldownMs: 300000, + activityWindowMs: 600000, + reward: { min: 100, max: 500, currency: "AU" } + } + } + }); + + useEffect(() => { + if (open) { + setLoading(true); + Promise.all([ + fetch("/api/settings").then(res => res.json()), + fetch("/api/settings/meta").then(res => res.json()) + ]).then(([config, metaData]) => { + form.reset(config as any); + setMeta(metaData); + }).catch(err => { + toast.error("Failed to load settings"); + console.error(err); + }).finally(() => { + setLoading(false); + }); + } + }, [open, form]); + + const onSubmit = async (data: FormValues) => { + try { + const response = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data) + }); + + if (!response.ok) throw new Error("Failed to save"); + + toast.success("Settings saved successfully", { + description: "Bot configuration has been updated and reloaded." + }); + setOpen(false); + } catch (error) { + toast.error("Failed to save settings"); + console.error(error); + } + }; + + return ( + + + + + + + +
+ +
+ System Configuration +
+ + Manage bot behavior, economy, and game systems. Changes apply immediately. + +
+ + {loading ? ( +
+ +

Loading configuration...

+
+ ) : ( +
+ + +
+ + + + General + + + + Economy + + + + Systems + + + + Roles + + +
+ + +
+ + {/* GENERAL TAB */} + +
+
+ + Onboarding + +
+ +
+ ( + + Welcome Channel + + Where to send welcome messages. + + )} + /> + + ( + + Welcome Message Template + +