Compare commits

..
812 Commits
Author SHA1 Message Date
1tzEmerald 8fb88e9349 Fixed opening tickets 2026-08-19 22:27:07 +01:00
1tzEmerald 2f5b17b13e dashboard fix with open tickets 2026-08-12 00:11:44 +01:00
1tzEmerald 676283cce9 Dashboard Update 2026-08-10 20:14:47 +01:00
1tzEmerald 6165f2d4f3 26.2-pre2 2026-08-06 17:00:12 +01:00
1tzEmerald ba41125eae Added ESRV API KEY 2026-07-26 19:55:52 +01:00
1tzEmerald ed57dc2d4f Dashboard Release 1.0 2026-07-26 19:44:57 +01:00
1tzEmerald 033fb87269 Dashboard Release 2026-07-26 19:14:35 +01:00
1tzEmerald 4fb26f3996 Updater Bug 2026-07-26 18:43:58 +01:00
1tzEmerald 1a6b360701 Dashboard 1.0
Added Addon Builder
Added Emerald SRV API for One-Click Addon install
Added API Config
Update Checker for Bot and Panel
2026-07-26 18:38:25 +01:00
1tzEmerald 24ccc179a6 Dashboard 1.0 2026-07-26 15:02:50 +01:00
1tzEmerald 51ffafeeda Dashboard Int. 2026-07-26 14:23:55 +01:00
Char 3f43d10ae4 Merge Emerald-Services main and keep project README. 2026-07-26 13:18:00 +01:00
Char b82f69783d Add dashboard UI source and track it in the repository.
Expose the Vite/React website source under dashboard/ (with node_modules still ignored) so the full dashboard can be built and maintained from this repo.
2026-07-26 13:09:19 +01:00
Char e97e281bda Merge branch 'release' of https://github.com/C-h-a-r/SupportBot-Dashboard into release 2026-06-20 14:30:49 +01:00
Char 00cebb7bb5 accent colour updated 2026-06-20 14:30:23 +01:00
1tzEmerald 942d71040b Merge branch '26.2' into release 2026-06-02 13:09:54 +01:00
1tzEmerald 92d21e7fc0 Update README.md 2026-05-27 20:46:27 +01:00
Char 07e4705a6a files 2026-05-26 20:22:57 +01:00
Char 6320296f0f updates 2026-05-26 20:11:22 +01:00
Char f4b14e6c01 Add Dockerfile for Node.js application setup 2026-05-24 14:04:37 +01:00
Char 7e22831d08 v26.3 2026-05-19 23:22:11 +01:00
Char 9332fc9254 v26.2 2026-05-16 17:36:50 +01:00
Char 018c949104 transcripts 2026-05-16 17:35:39 +01:00
Char b55976508e public files 2026-05-16 16:46:44 +01:00
Char 264dcb3053 added dashboard files 2026-05-16 16:44:04 +01:00
Char 22c33c0f7a dashboard v1 2026-05-16 15:10:27 +01:00
Nyo 57d420a9f4 Update supportbot.yml 2026-05-12 16:31:37 +02:00
Nyo f8cc08ba3e Bump version from 26.0.0-prerelease-1 to 26.1.1 2026-05-12 16:31:23 +02:00
Nyo 60d5c4cb1f Change event name from 'ready' to 'clientReady' 2026-05-12 16:30:29 +02:00
Nyo 0288e2f4de Normalize channel, role, and category name comparisons 2026-05-12 16:30:12 +02:00
Nyo 33d5a79759 Refactor image handling in buildModernComponents
Refactored the buildModernComponents function to use MediaGalleryBuilder for image handling instead of TextDisplayBuilder. Cleaned up code formatting and removed unnecessary whitespace.
2026-05-12 16:29:54 +02:00
1tzEmerald 30b89a3cf9 Update supportbot.yml 2026-04-25 22:31:11 +01:00
1tzEmerald 427dc2d040 Merge pull request #209 from Emerald-Services/Dev-(26.1)
26.1
2026-04-25 21:57:00 +01:00
1tzEmerald 033f32d596 26.1 Dev-6
- Further Dashboard Improvements
- Bug Fixes
2026-04-21 13:37:39 +01:00
1tzEmerald d5fa42b55d Merge pull request #206 from Emerald-Services/26.0
26.0
2026-03-27 16:45:39 +00:00
1tzEmerald 4bb35b1e50 Update account setup instructions for SupportBot AI 2026-03-27 16:31:40 +00:00
1tzEmerald d451857d7b Update SupportBot version in configuration file 2026-03-27 16:31:07 +00:00
1tzEmerald 78b804176a 26.0 2026-03-27 16:25:59 +00:00
1tzEmerald 15b780ab62 26.0-build-2 2026-03-11 18:58:15 +00:00
1tzEmerald 9448c4e3d6 26.0-build-1 2026-03-10 20:02:43 +00:00
1tzEmerald 4af9759700 Update SupportBot version to v8.5 2026-02-14 13:32:59 +00:00
1tzEmerald 835cbbe937 Delete package-lock.json 2026-02-14 13:32:10 +00:00
1tzEmerald 491bb9ba65 . 2026-02-14 13:14:05 +00:00
1tzEmerald ced29535f9 Merge pull request #201 from Emerald-Services/8.5
8.5 Update
2026-02-14 13:11:56 +00:00
1tzEmerald d99e9a105b 8.5 Update
Added TicketRename command
2026-02-14 13:05:07 +00:00
1tzEmerald c72c7c5c34 Merge pull request #200 from Emerald-Services/1tzemerald-patch-3
Delete .idea directory
2026-02-03 16:37:22 +00:00
1tzEmerald 23960bc30d Delete .idea directory 2026-02-03 16:37:08 +00:00
1tzEmerald abb48c281b Merge branch 'release' of https://github.com/Emerald-Services/SupportBot into release 2026-02-03 16:33:08 +00:00
1tzEmerald 7acb825713 Added rename command 2026-02-03 16:33:02 +00:00
1tzEmerald dd2f329485 Merge pull request #199 from Emerald-Services/1tzemerald-patch-2
Update Node.JS version for SupportBot 8.x
2025-11-22 12:52:33 +00:00
1tzEmerald 75577ec0bd Update Node.JS version for SupportBot 8.x 2025-11-22 12:52:23 +00:00
1tzEmerald 7366bcda71 Merge pull request #198 from Emerald-Services/1tzemerald-patch-1
Change support status for SupportBot 7.7.3
2025-11-22 12:51:59 +00:00
1tzEmerald 23e43cfad3 Change support status for SupportBot 7.7.3
Updated support status for SupportBot versions.
2025-11-22 12:51:47 +00:00
Nyo ad9a8085aa Merge pull request #197 from Emerald-Services/ItsNyoty-patch-1
Fix link format for BuiltByBit in README.md
2025-09-30 18:50:12 +02:00
Nyo 2f23e8f23d Fix link format for BuiltByBit in README.md 2025-09-30 18:49:52 +02:00
1tzEmerald 4c07b8a236 Merge pull request #196 from Emerald-Services/dev
8.4.1 guilldMemberAdd update.
2025-09-25 13:16:42 +01:00
1tzEmerald 571bd586fa 8.4.1 guilldMemberAdd update.
Added if statement to .setAccentColor allowing you to remove the color from the config.
2025-09-25 13:16:20 +01:00
1tzEmerald eac2d88873 Merge pull request #195 from Emerald-Services/dev
8.4.1 small fix
2025-09-25 13:10:23 +01:00
1tzEmerald 93c793f393 Merge branch 'dev' of https://github.com/Emerald-Services/SupportBot into dev 2025-09-25 13:09:09 +01:00
1tzEmerald 1aa97b63f9 8.4.1 small fix 2025-09-25 13:09:00 +01:00
1tzEmerald c1b76a2b84 Merge pull request #194 from Emerald-Services/dev
8.4 from Dev to Release
2025-09-23 23:26:30 +01:00
1tzEmerald a1429aa723 Merge branch 'release' into dev 2025-09-23 23:26:13 +01:00
1tzEmerald 2de7c7b19c 8.4 2025-09-23 23:23:19 +01:00
1tzEmerald 7383bd2c48 8.4-DEV-2
Added Buttons to WelcomeMessage
Added Components v2 Support for Welcome Message
Added improved logging system
2025-09-22 18:36:04 +01:00
1tzEmerald a05d65099b 8.4 DEV 1
Changed JSON to SQLlite
Added Suggestion Forums
Added /suggestadmin
2025-09-20 23:09:22 +01:00
1tzEmerald c6db929d12 8.4-Dev 1
Added Suggestions mode switch
2025-08-07 13:54:35 +01:00
1tzEmerald dc88f6c51a Merge pull request #186 from chorimethod/release
Added "InvalidChannel" message (required by suggest.js), fixed a double quote that was preventing the bot to start correctly.
2025-07-14 16:37:45 +01:00
Nyo d69fb0e26d Update supportbot.yml 2025-05-22 20:30:06 +02:00
chorisancio3d 4b2d53d3f4 Added "InvalidChannel" message (required by suggest.js)
Fixed a double quote that was preventing the bot to start correctly.
2025-02-03 21:43:03 -03:00
1tzEmerald e3fa8b6d72 Merge pull request #185 from Emerald-Services/dev
Update close.js
2024-11-10 13:31:17 +00:00
1tzEmerald 5c6a9f7306 Update close.js 2024-11-10 13:30:42 +00:00
1tzEmerald 71e33dbf66 Update close.js 2024-11-10 13:30:27 +00:00
1tzEmerald d468ab1575 Delete Data/Profiles directory 2024-11-10 13:18:50 +00:00
1tzEmerald 74482af471 Merge pull request #184 from Emerald-Services/dev
Dev
2024-11-09 13:04:16 +00:00
1tzEmerald 0b8b092cb9 Update profile.js 2024-11-09 13:03:55 +00:00
1tzEmerald 3cdfaef942 Update profile.js 2024-11-09 13:03:41 +00:00
1tzEmerald fcf441a190 Update close.js 2024-11-09 02:46:35 +00:00
1tzEmerald 5a1c4ad99c Update close.js 2024-11-09 02:46:25 +00:00
1tzEmerald d9cb72f22e Update close.js 2024-11-09 02:44:48 +00:00
1tzEmerald 58eb37124e Update close.js 2024-11-09 02:44:39 +00:00
1tzEmerald afeeda3a8d Merge pull request #183 from Emerald-Services/dev
Dev
2024-11-09 02:38:20 +00:00
1tzEmerald fece90d2bd Update close.js 2024-11-09 02:37:26 +00:00
1tzEmerald 357a85fa3d Update close.js 2024-11-09 02:37:14 +00:00
1tzEmerald f34158caa6 Update close.js 2024-11-09 02:32:52 +00:00
1tzEmerald 1bb343e49a Update ticket.js 2024-11-09 02:32:28 +00:00
1tzEmerald dffa5d4161 Update close.js 2024-11-09 02:32:06 +00:00
1tzEmerald 13590b08a8 Update ticket.js 2024-11-09 01:07:16 +00:00
1tzEmerald 6e14ce209d Merge pull request #182 from Emerald-Services/dev
8.3 Dev -> Release
2024-11-09 00:45:40 +00:00
1tzEmerald 72b8c2247e Merge branch 'release' into dev 2024-11-09 00:45:28 +00:00
1tzEmerald 925504a928 Merge branch 'dev' of https://github.com/Emerald-Services/SupportBot into dev 2024-11-09 00:37:53 +00:00
1tzEmerald 236d67707f 8.3 Update 2024-11-09 00:37:51 +00:00
1tzEmerald 753f432c95 Merge pull request #179 from Emerald-Services/1tzemerald-patch-4
Update package.json
2024-10-11 15:04:37 +01:00
1tzEmerald c824bc51b2 Update package.json 2024-10-11 15:03:47 +01:00
1tzEmerald 39ee540b55 Merge pull request #177 from Emerald-Services/dev
8.2
2024-10-11 15:00:05 +01:00
1tzEmerald 903b214051 Merge branch 'release' into dev 2024-10-11 14:59:50 +01:00
1tzEmerald ac0daf5fe7 8.2 2024-10-10 19:14:45 +01:00
ItsNyoty 80e38a07e9 Merge pull request #175 from Emerald-Services/ItsNyoty-patch-1
Update README.md
2024-09-19 13:06:05 +02:00
ItsNyoty d2598e09f7 Update README.md 2024-09-19 13:05:26 +02:00
ItsNyoty 28aab47150 Update README.md 2024-09-19 13:04:44 +02:00
1tzEmerald 81bda67ae4 Merge pull request #172 from Emerald-Services/1tzemerald-patch-3
Update supportbot.yml
2024-07-30 17:58:41 +01:00
1tzEmerald 25bbd1ff85 Update supportbot.yml 2024-07-30 17:58:01 +01:00
1tzEmerald 164d695a5f Merge pull request #171 from Emerald-Services/1tzemerald-patch-2
Create 0000000000000000.json
2024-07-30 17:57:33 +01:00
1tzEmerald 57e4a3e77c Create 0000000000000000.json 2024-07-30 17:57:12 +01:00
1tzEmerald a6ac532b2b Merge pull request #169 from Emerald-Services/dev
8.0.1 Dev -> Release Branch
2024-07-30 17:47:13 +01:00
1tzEmerald 4f14722e14 8.0.1 Update 2024-07-30 17:45:35 +01:00
1tzEmerald 9a6eece7a1 v8.1 2024-07-09 22:49:49 +01:00
1tzEmerald fd65d0a9a7 8.1-BETA 2024-07-03 13:46:57 +01:00
ItsNyoty 48500fe8a4 Update README.md 2024-07-03 13:42:46 +02:00
1tzEmerald 8d8d9e4bee 8.0.1 Bug Fix 2024-06-13 18:01:41 +01:00
1tzEmerald dcbddf9f23 8.0.1 - Bug Fix 2024-06-13 17:36:44 +01:00
1tzEmerald 7449c0a61a License files 2024-06-12 12:55:45 +01:00
1tzEmerald c95b8ccb32 SupportBot 8.0 2024-06-12 12:54:31 +01:00
1tzEmerald 2c5bfe8e33 Merge pull request #135 from ItsNyoty/patch-1
Update messages.yml
2022-09-05 13:42:52 +01:00
ItsNyoty a3bbd97333 Update messages.yml
TicketExists wasn't defined before
2022-08-29 14:58:01 +02:00
1tzEmerald 4ea488951d Delete oldconfig.yml 2022-08-12 01:38:07 +01:00
1tzEmerald 481424d5a6 Update LICENSE.md 2022-08-12 01:37:49 +01:00
1tzEmerald fe82e96cc1 Update LICENSE.md 2022-08-12 01:37:31 +01:00
1tzEmerald 976258c959 Delete .gitignore 2022-08-12 01:37:14 +01:00
1tzEmerald 83cf0689e2 Delete .DS_Store 2022-08-12 01:37:00 +01:00
ItzEmerald c911cb4423 v8.0 New configuration files 2022-08-12 01:35:36 +01:00
ItzEmerald 4eadef8b65 8.0 Dev 1.2
Reworked configuration files & completed upgrade to V14
2022-08-11 19:19:29 +01:00
ItzEmerald 4e081358ff v8.0-Dev-1.1 2022-07-23 07:50:12 +01:00
ItzEmerald 209ec0b435 v8.0 - Dev-1 2022-07-22 13:19:05 +01:00
Xiled Emerald 6d363abb54 Merge pull request #129 from SypherRed/dev
Better looking readme
2022-07-01 22:19:03 +01:00
SypherRed 6f8b84aeda Update README.md 2022-07-01 23:16:16 +02:00
SypherRed 5b8c5da33a Update README.md 2022-07-01 23:14:01 +02:00
Xiled Emerald b6ef0f95cb Merge pull request #126 from SypherRed/release
Muted Role check and issue fix
2022-06-30 15:49:14 +01:00
SypherRed cce1480a42 remove empty declaration
AboutMe was removed in a previous version.
2022-06-29 13:12:56 +02:00
ItzEmerald 44fbe9f2d6 FINAL 7.7 2022-06-28 03:15:52 +01:00
ItzEmerald 29db6c7d67 7.7 FINAL BETA 2022-06-28 03:14:54 +01:00
SypherRed 26e9b56961 Issue fix with Muted Role check
Fixed the issue to recognize a Muted Role and made changes to ready.js accordingly
2022-06-27 19:03:34 +02:00
SypherRed 979271cfb5 beta push of muted check
push of a check if author has a muted role on ticket opening, if so, decline the interaction

Also either check for missing role or not
2022-06-23 20:58:03 +02:00
ItzEmerald 69f0b00ed3 7.7 2022-06-23 00:05:08 +01:00
ItzEmerald c1a1839457 7.7 BETA 3 2022-06-22 23:30:59 +01:00
Xiled Emerald dd4b53800e Merge pull request #124 from Emerald-Services/add-license-1
Create LICENSE.md
2022-06-22 23:05:38 +01:00
Xiled Emerald 1fc94528fc Merge pull request #123 from SypherRed/release
embed fix and updates
2022-06-22 23:05:25 +01:00
SypherRed bb1a817980 fix previous push of embed
fixed the if-statement of embed
2022-06-22 20:59:45 +02:00
SypherRed 53305acbc7 Fixed embed command
Added a check if the field options are used, if not leave it ignored and don't add some.

This was fixed due to ticket-0038.
2022-06-22 18:21:25 +02:00
Xiled Emerald d0f1bae0bd Create LICENSE.md 2022-06-22 02:24:19 +01:00
SypherRed c0cf3b1bb7 Update several files
* added to .gitignore ".DS_Store"

* changed commented head in "suggest.js" and "translate.js"

* Removed "AboutMe" due it to being unfunctional and can be set in the Developer Portal

* Removed the icon in "ticket-panel.yml"

* Updated dependencies and changed version to match current
2022-06-21 09:54:22 +02:00
Xiled Emerald ce67b668d5 Update Client.js 2022-06-21 01:42:59 +01:00
Xiled Emerald 3273cfd0e1 Update help.js 2022-06-21 01:41:53 +01:00
Xiled Emerald fd992a6514 Update supportbot.yml 2022-06-21 01:41:17 +01:00
Xiled Emerald 550d681fe1 Merge pull request #122 from RandomTomato/release
Added 3 new settings in supportbot.yml for more customization
2022-06-21 01:39:35 +01:00
ItzEmerald 7d29226953 BETA 2 - V7.7 2022-06-21 01:38:22 +01:00
BuildTools 38da72ca91 Added 3 new settings in supportbot.yml for more customization 2022-06-18 11:19:12 +09:30
Xiled Emerald eab11361cb Update ticket-panel-id.json 2022-06-16 22:30:13 +01:00
Xiled Emerald b281bba36f Merge pull request #120 from Emerald-Services/beta
7.6 Release
2022-06-16 22:26:50 +01:00
ItzEmerald 5e38573aed 7.6 2022-06-16 22:12:14 +01:00
ItzEmerald da7b6d487c 7.6-Pre1
Whats New?
  - Ability to add as many departments as you need (Don't go to crazy 😂 )
  - Merged Sypher's Translate & UserInfo Addon into Features

------------

  - Add a role automatically when a user joins the server
  - Announce a welcome message with full cusomtization
  - Announce a leave message with full cusomtization
  - Added "ephermal" to majority messages.

Install modules use - npm install
2022-06-16 02:31:58 +01:00
Xiled Emerald 6bf4e5f4bb Update README.md 2022-06-14 23:02:12 +01:00
Xiled Emerald 213f532a3c Merge pull request #114 from Emerald-Services/dev
7.5 push
2022-02-27 04:37:24 +00:00
ItzEmerald 45bd88e4d2 7.5 commit 2022-02-27 04:30:15 +00:00
Xiled Emerald 4eb8658459 Update supportbot.yml 2022-02-27 00:39:36 +00:00
Noah b37118fee2 Fix addon handler 2022-02-25 21:57:54 -06:00
Noah 026caba68a Fix setFooters 2022-02-25 21:55:33 -06:00
Noah 2003f8801a Fixed ticket perms 2022-02-25 21:51:22 -06:00
Noah 473bacf13a Removed Guild 2022-02-17 19:57:37 -06:00
Noah 8b3f2bc61a Fix missing user or subuser 2022-02-17 19:57:11 -06:00
Noah 3aa373c33c fix bad code 2022-02-11 23:47:06 -06:00
Noah 9b6263e583 Fix DM help menu 2022-02-11 23:41:02 -06:00
Noah 3e066d08ce New Departments 2022-02-11 23:34:38 -06:00
Noah aa48e28162 Fix Typos 2022-02-04 20:00:31 -06:00
Noah dc660ed94a Bug Fixes 2022-02-04 17:56:11 -06:00
Noah e02ecf9553 Bug fixes 2022-02-04 17:55:02 -06:00
Noah 23acc0926f Fix BitField Error 2022-02-04 17:49:02 -06:00
Xiled Emerald 3fc1cd18d5 Merge pull request #109 from Emerald-Services/dev
7.4.2 Pull Request
2022-02-03 17:26:54 +00:00
Noah ebe29b403e Prettier 2022-01-30 00:07:04 -06:00
Noah dcba3312dd Misc 2022-01-29 23:57:10 -06:00
Noah 7fddeeec37 Change Max to 100 2022-01-10 17:48:31 -06:00
Noah b5341b023f Fix embed command 2021-12-29 10:20:29 -06:00
Noah bbb7443ef2 Fix suggest command 2021-12-28 19:06:35 -06:00
Noah 22df347bb9 Merge pull request #108 from astranger1k/dev
Updated Documentation URLs + Added invite URL to log upon launch.
2021-12-23 09:18:47 -06:00
Kieran Davies 9aed6b8f35 Updated Documentation URLs + Added invite URL to log upon launch. 2021-12-23 11:48:51 +00:00
Noah 9e7ec9f1bd 7.4.2 2021-12-19 13:06:32 -06:00
Noah 373f43eac5 Channel can now be id or name 2021-12-19 13:03:33 -06:00
Noah 6c91b2791a Fix users can't send messages 2021-12-19 13:02:43 -06:00
Noah bde62a431f Merge branch 'dev' into release 2021-12-18 22:59:41 -06:00
Noah 615a074540 move filter 2021-12-18 22:59:05 -06:00
Noah 09b335ac46 Update ticket.js 2021-12-18 22:42:51 -06:00
Noah 2701880c77 Merge pull request #106 from Emerald-Services/dev
v7.4.1 Release
2021-12-18 22:37:29 -06:00
Noah 24c8f2fb38 v7.4.1 2021-12-18 22:28:08 -06:00
Noah c3ec158ac3 Fix id 2021-12-18 22:23:18 -06:00
Noah b3690222d0 Fixed id 2021-12-18 22:18:05 -06:00
Noah 4d7583785b Fixed ticket id 2021-12-18 22:14:11 -06:00
Noah 17ad1a15db Delete .DS_Store 2021-12-18 21:32:21 -06:00
Noah 4c89de9e76 Merge pull request #103 from Emerald-Services/dev
v7.4 Release
2021-12-18 21:31:56 -06:00
Noah 5f25af41f9 Merge branch 'release' into dev 2021-12-18 21:30:53 -06:00
Noah 3a3ee357fc Update packages 2021-12-18 21:17:06 -06:00
Noah 08a6d27903 Change to ephemeral 2021-12-18 20:56:30 -06:00
Noah 33f7675aac Added Max Ticket Count + Timeout 2021-12-18 20:54:01 -06:00
Noah 1178a0a611 Add StaffOnly close option 2021-12-18 19:39:59 -06:00
Noah 96ded77bec Fixed perms bug 2021-12-18 19:24:39 -06:00
Noah e83ca8cb2b add guild 2021-12-18 18:58:02 -06:00
Noah e507176d39 New addon handler 2021-12-18 18:57:45 -06:00
Noah 4e554b8bee rename slashCommandOptions to options, and permission to permissions 2021-12-18 18:57:26 -06:00
Noah bc649caa99 remove unused code 2021-12-18 18:56:25 -06:00
Noah f7a4c7b616 rename var + remove unused code 2021-12-18 18:56:02 -06:00
Noah 2c7be5882c Added getRole, getChannel, getCategory functions 2021-12-15 00:19:40 -06:00
Noah 6107d268f7 Fixed close + Send transcript to subusers 2021-12-14 18:04:38 -06:00
Noah 7760699155 Update node.js.yml 2021-12-04 21:30:48 -06:00
Noah acf349c72e Fix test mode 2021-12-04 21:26:55 -06:00
Noah 8096e5b882 Add npm test 2021-12-04 21:25:02 -06:00
Noah 873cfc7dc2 Merge branch 'dev' of https://github.com/Emerald-Services/SupportBot into dev 2021-12-04 21:22:10 -06:00
Noah 038174bc7e temp 2021-12-04 21:22:08 -06:00
Noah 14881096bb Update node.js.yml 2021-12-04 21:22:07 -06:00
Noah ed1905d8f4 Added test mode 2021-12-04 21:19:51 -06:00
Noah 72c0a2efa3 Update node.js.yml 2021-12-04 21:07:27 -06:00
Noah e599d48713 Update node.js.yml 2021-12-04 21:05:36 -06:00
Noah 7f44634d4f Delete prettier.yml 2021-12-04 21:04:47 -06:00
Noah 44170caac6 Update supportbot.yml 2021-12-04 21:01:34 -06:00
Noah adedfadd92 Prettier 2021-12-04 20:58:19 -06:00
Noah 6b01d0c8fe Possibly fix /close? 2021-12-04 20:57:23 -06:00
Noah d4d96f59ab Update 2021-12-04 20:25:49 -06:00
Noah a1bf7ed6bd Fix syntax 2021-12-04 20:25:35 -06:00
Noah 306e20efc9 Merge pull request #99 from DarkerInker/patch-1
wrong way to send embeds
2021-12-04 20:21:07 -06:00
Noah f593f6e0a8 Update close.js 2021-12-04 20:20:55 -06:00
DarkerInker 74646a4318 wrong way to send embeds
You are using the old way to send embeds. I'm not sure how you forgot about this one area but everyone makes mistakes :D
2021-12-02 13:52:07 -06:00
Noah 8bfcba8832 Merge pull request #97 from SypherRed/dev
fixed help command
2021-11-28 23:35:31 -06:00
SypherRed 4ba5621a4d Merge branch 'dev' into dev 2021-11-28 14:55:48 +01:00
N0ahMC 1f928324c2 Prettified Code! 2021-11-26 19:18:15 +00:00
Noah 3771872cd2 Update prettier.yml 2021-11-26 13:17:57 -06:00
Noah f359a60343 Update node.js.yml 2021-11-26 13:16:15 -06:00
Noah f53afd7bc9 Create prettier.yml 2021-11-26 13:13:15 -06:00
Noah e7dcc2ab29 Update help.js 2021-11-26 12:56:21 -06:00
SypherRed 0118b9e591 fixed help command 2021-11-25 01:51:56 +01:00
Noah ffe42f9777 Update ticket.js 2021-11-24 18:14:07 -06:00
Noah 5a467599e9 Update hello.js 2021-11-24 00:15:16 -06:00
Noah c56e78484a Update dependencies 2021-11-23 20:29:22 -06:00
Noah 1ee0d4a09e Update close.js 2021-11-23 18:09:06 -06:00
Noah ffdce5820b Fix close button 2021-11-23 18:08:45 -06:00
Noah 9fed504f37 Fixed /close 2021-11-23 17:37:25 -06:00
Noah 6c5799be0d Fix ID 2021-11-23 17:32:57 -06:00
Noah b6747a0340 Made warning ephemeral 2021-11-23 16:51:28 -06:00
Noah cd6df02e30 Changed TicketLog_Title 2021-11-23 15:50:56 -06:00
Noah edbef6a36f Fixed close command 2021-11-23 15:49:37 -06:00
Noah b6a0016f5f Changed TicketData Structure 2021-11-23 15:48:45 -06:00
Noah e740c17837 Update package-lock.json 2021-11-21 18:42:24 -06:00
Noah 7eb949f109 Better finds 2021-11-21 18:42:18 -06:00
Noah 02394e310d Exit if joins or leaves guild 2021-11-17 19:04:38 -06:00
Noah ef83832819 Added missing role and channel check 2021-11-17 19:04:20 -06:00
Noah a82101ab30 Register commands in Guild 2021-11-17 19:03:35 -06:00
Noah 007c78930e Added Guild to config 2021-11-17 19:03:11 -06:00
Noah 300c83512c Fixed token, and changed version for future 2021-11-11 17:18:30 -06:00
Noah b674872c56 Fixed Archive Bug 2021-11-10 21:41:12 -06:00
Noah 12a7680275 Fix spelling 2021-11-10 21:23:07 -06:00
Noah c9cf7c942b Prettier 2021-11-10 21:22:08 -06:00
Noah 8e0326c8f8 Removed HelpMenu 2021-11-10 21:21:50 -06:00
Noah 13972119a8 Update package-lock.json 2021-11-10 21:21:05 -06:00
Noah acf165a119 Add Button Emoji 2021-11-10 21:18:49 -06:00
Xiled Emerald bdff7c6069 Update supportbot.yml 2021-11-07 21:39:53 +00:00
Xiled Emerald e2e3376c87 Merge pull request #91 from Emerald-Services/dev
Dev
2021-10-29 23:38:32 +01:00
Xiled Emerald 3c613088e5 Delete supportbot.yml 2021-10-29 23:38:18 +01:00
Xiled Emerald 7e9ece50a0 Update issue templates 2021-10-29 22:40:12 +01:00
Xiled Emerald af81b6536a Update issue templates 2021-10-29 22:38:13 +01:00
ItzEmerald 4cd3ecf81f 7.3 2021-10-29 19:12:11 +01:00
Noah 31f6b9e08e Fix suggestions 2021-10-28 19:08:06 -05:00
Noah 02de920cb3 Update package.json 2021-10-28 18:39:06 -05:00
Noah 84c0905297 Update version numbers 2021-10-28 18:07:43 -05:00
Noah 250583f859 Delete commands directory 2021-10-28 18:04:26 -05:00
Noah ee7d42196d Create remove.js 2021-10-28 18:03:51 -05:00
Noah a397de2b9f Create close.js 2021-10-28 18:03:27 -05:00
Noah 0610df9400 Create add.js 2021-10-28 18:03:06 -05:00
Noah ed583deb31 Updated css 2021-10-27 20:44:10 -05:00
Noah e039048fc6 Updated role defaults 2021-10-27 20:24:26 -05:00
Noah f2ea9650c4 Renamed file 2021-10-27 20:24:10 -05:00
Noah 1e89057006 Added font 2021-10-27 20:19:33 -05:00
Noah 019bb4842f Fixed bug 2021-10-27 20:19:27 -05:00
Noah 4cb45beded Fixed bug 2021-10-27 20:18:06 -05:00
Noah 8a2059f087 Fixed suggest command bug 2021-10-27 20:05:45 -05:00
Noah a6b105a2e1 Updated config defaults 2021-10-27 19:48:00 -05:00
Noah 6c14a6a06c Rename 2021-10-27 19:39:07 -05:00
Noah cddd5d0026 Moved configs from Data to Configs 2021-10-27 19:30:10 -05:00
Noah 78dea91e1d Prettier 2021-10-27 19:20:10 -05:00
Xiled Emerald 3df591ec52 Update supportbot.yml 2021-10-25 23:19:07 +01:00
Xiled Emerald 1c4032caa6 Create suggest.js 2021-10-25 23:18:28 +01:00
Noah 465c422171 Update README.md 2021-10-25 00:16:00 -05:00
Noah 284e8dc6f5 Fixed spelling 2021-10-25 00:15:54 -05:00
Noah 3fc905c82e Update supportbot.yml 2021-10-25 00:03:28 -05:00
Noah 55a42b88ae Ran prettier 2021-10-25 00:02:23 -05:00
Noah bd26162e4a Fixed bug 2021-10-25 00:02:17 -05:00
Xiled Emerald 62e7e33406 Update Client.js 2021-10-25 00:13:27 +01:00
Xiled Emerald 2f0ddcec3f Merge pull request #77 from 1tzemerald/SupportBot-BETA
SupportBot 7.1 merge to main branch
2021-10-24 22:56:37 +01:00
Xiled Emerald 7cdfa68e0e Delete supportbot-config.yml 2021-10-24 22:56:14 +01:00
Xiled Emerald f8939a964d Delete .DS_Store 2021-10-24 22:54:15 +01:00
Xiled Emerald 7830e5dda5 Add files via upload 2021-10-24 22:48:25 +01:00
Xiled Emerald 6aa2f22294 Create ready.js 2021-10-24 22:47:42 +01:00
Xiled Emerald 50b14e3d44 Add files via upload 2021-10-24 22:46:36 +01:00
Xiled Emerald e09c2192c2 Delete events directory 2021-10-24 22:45:44 +01:00
Xiled Emerald b725d2cc62 Delete messageReactionAdd.js 2021-10-24 22:44:51 +01:00
Xiled Emerald abf3f617a4 Delete ticket.js 2021-10-24 22:44:29 +01:00
Xiled Emerald 1eda7e66fe Delete ping.js 2021-10-24 22:44:11 +01:00
ItzEmerald b5000531c4 Merge branch 'SupportBot-BETA' of https://github.com/1tzemerald/SupportBot into SupportBot-BETA 2021-10-24 22:42:49 +01:00
ItzEmerald d4f95e752b SupportBot-7.2-Release 2021-10-24 22:41:46 +01:00
Noah e31c611f70 Removed prefix 2021-10-23 20:17:58 -05:00
Noah 085481bee6 Spelling Mistakes 2021-10-23 20:15:07 -05:00
Noah cf610fbe49 Ran prettier 2021-10-23 20:12:04 -05:00
Noah b681eb3cd2 Create .gitignore 2021-10-23 20:10:49 -05:00
Noah f8ffdf0e81 Delete node_modules 2021-10-23 20:10:16 -05:00
Noah ba38bce1da Updated structures 2021-10-23 20:09:15 -05:00
Noah 3a39b05fe4 Updated packages 2021-10-23 20:08:55 -05:00
Noah 27d2982b28 Removed text commands 2021-10-23 20:07:37 -05:00
Noah 33e4d97be5 Updated for slash commands 2021-10-23 20:04:02 -05:00
Xiled Emerald 654064d7c5 Create messageReactionAdd.js 2021-10-23 22:44:59 +01:00
Noah aeb0da9e0d Removed console.log 2021-10-23 16:26:07 -05:00
Noah cb1a2fe067 Fixed Slash commands 2021-10-23 16:23:02 -05:00
ItzEmerald 85667ddfad 7.2 pre-3 2021-10-21 19:28:23 +01:00
Alexersion 7ad5016966 Unneeded workspace updates removed. 2021-10-13 11:12:08 -04:00
ItzEmerald 45c3315fd0 Update .DS_Store 2021-10-07 20:46:20 +01:00
ItzEmerald c5f550dd00 Pre-2 2021-10-07 20:46:18 +01:00
ItzEmerald 37c870c7ab SupportBot Pre-7.2
SupportBot Build for DiscordJS 13
2021-10-04 20:41:05 +01:00
Xiled Emerald 155b768f73 Update supportbot-config.yml 2021-10-04 13:56:30 +01:00
ItzEmerald 09f0873f27 SupportBot 7.3 2021-10-04 13:54:30 +01:00
ItzEmerald 19a12f6c8f SupportBot 7.2 2021-09-11 15:29:21 +01:00
Xiled Emerald 79a498d1d6 Update transcript.js 2021-08-07 16:19:13 +01:00
ItzEmerald 12a03c36f3 SupportBot 7.1 Release
Features all-new ticket system with new reaction technology enabling you to delete & lock tickets with one-click!
2021-08-06 16:34:15 +01:00
ItzEmerald ddc8bcab9b Pre-2 2021-08-05 13:40:19 +01:00
ItzEmerald 35dacfd48a Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2021-08-05 13:39:48 +01:00
ItzEmerald 611cef34ab 7.1-Pre-2 2021-08-05 13:39:44 +01:00
Xiled Emerald 8a8f9fe890 Update SupportTickets.json 2021-08-05 01:07:16 +01:00
Xiled Emerald 5a65b70c80 Update ticketreaction.yml 2021-08-05 01:07:04 +01:00
Xiled Emerald 8bea3faa1f Update TicketID.json 2021-08-05 01:06:53 +01:00
ItzEmerald 4125954d53 7.1 Pre-1
Revamp of ticket system
2021-08-05 01:03:58 +01:00
Xiled Emerald 3d9c5a729e Create SupportBot_ENG.yml 2021-08-01 16:21:47 +01:00
Xiled Emerald 51d775ea6d Delete supportbot-config_GER.yml 2021-08-01 16:20:25 +01:00
Xiled Emerald e461c47c4b Delete supportbot-config_FR.yml 2021-08-01 16:20:19 +01:00
Xiled Emerald bbdb38e45a Delete supportbot-config_ES.yml 2021-07-31 15:33:37 +01:00
Xiled Emerald 083e047aec Delete supportbot-config_ENG.yml 2021-07-31 15:33:30 +01:00
Xiled Emerald 84e0b504ab Delete .DS_Store 2021-06-30 21:33:35 +01:00
Xiled Emerald bedff6832c Update supportbot-config.yml 2021-06-30 21:33:16 +01:00
ItzEmerald 6c29e04de2 7.0 2021-06-30 21:30:41 +01:00
ItzEmerald ba674323e1 Update help.js 2021-06-15 21:05:38 +01:00
ItzEmerald 6d756a161e Update help.js 2021-06-15 21:04:32 +01:00
ItzEmerald 66a5cd95ec Update README.md 2021-06-15 19:56:39 +01:00
ItzEmerald 9f70648264 Update suggest.js 2021-06-15 19:10:17 +01:00
ItzEmerald b975b79691 Update removeUser.js 2021-06-15 19:10:07 +01:00
ItzEmerald 3cab896954 Update purge.js 2021-06-15 19:09:58 +01:00
ItzEmerald b9680e5b35 Update ping.js 2021-06-15 19:09:46 +01:00
ItzEmerald 43a003ca7d Update newticket.js 2021-06-15 19:09:35 +01:00
ItzEmerald fbe1cf2009 Update links.js 2021-06-15 19:09:23 +01:00
ItzEmerald fdee61cc11 Update kick.js 2021-06-15 19:09:06 +01:00
ItzEmerald 8b51200d9c Update help.js 2021-06-15 19:08:56 +01:00
ItzEmerald 0631ab536a Update close.js 2021-06-15 19:08:46 +01:00
ItzEmerald 0d76e76519 Update botSay.js 2021-06-15 19:07:42 +01:00
ItzEmerald 9e9f83d211 Update blacklistUser.js 2021-06-15 19:07:26 +01:00
ItzEmerald 0829d477d9 Update ban.js 2021-06-15 19:07:10 +01:00
ItzEmerald 1b7109fa23 Update announce.js 2021-06-15 19:06:58 +01:00
ItzEmerald 366669ccd8 Update addUser.js 2021-06-15 19:06:40 +01:00
ItzEmerald e05edd89fa Update ready.js 2021-06-15 18:09:24 +01:00
ItzEmerald a405df66c9 Update message.js 2021-06-15 18:09:06 +01:00
ItzEmerald 28a57ead9f Update index.js 2021-06-15 18:08:32 +01:00
ItzEmerald 72bfacae75 Merge pull request #68 from DuAnti7423/master
Boolean Changes, help menu fix, raw Event, ID Support added
2021-05-24 12:48:25 +01:00
DuAnti7423 10f8821b3b Random Stuff
idk why github thinks i changed something
2021-05-23 10:45:00 +02:00
DuAnti7423 92a3745ce1 Update supportbot-config.yml
Added HelpMenu_WithKey Option.
Boolean Changes
2021-05-23 10:44:00 +02:00
DuAnti7423 aff7ff1451 Update ready.js
ID Support, Boolean Changes
2021-05-23 10:41:15 +02:00
DuAnti7423 9e63e0ff09 Create raw.js
Added Raw-Event, probably fixxed the ticketreaction isnt 100% workin
2021-05-23 10:41:05 +02:00
DuAnti7423 2c5ebd8620 Update messageReactionAdd.js
ID Support, Boolean Changes
2021-05-23 10:40:37 +02:00
DuAnti7423 e6d63db3a9 Update guildMemberRemove.js
ID Support, Boolean Changes
2021-05-23 10:40:32 +02:00
DuAnti7423 2cc316aafb Update guildMemberAdd.js
ID Support, Boolean Changes
2021-05-23 10:40:28 +02:00
DuAnti7423 4f25cfa0dd Update suggest.js
ID Support, Boolean Changes
2021-05-23 10:40:20 +02:00
DuAnti7423 0385de3882 Update removeUser.js
ID Support, Boolean Changes
2021-05-23 10:40:16 +02:00
DuAnti7423 2e1253c54e Update purge.js
ID Support, Boolean Changes
2021-05-23 10:40:10 +02:00
DuAnti7423 14df15caeb Update ping.js
ID Support, Boolean Changes
2021-05-23 10:40:03 +02:00
DuAnti7423 af481a2ec8 Update newticket.js
ID Support, Boolean Changes

Fixxed everyone permissions on ticket creation
2021-05-23 10:39:58 +02:00
DuAnti7423 96c1d80844 Update links.js
ID Support, Boolean Changes
2021-05-23 10:39:42 +02:00
DuAnti7423 57ea2e25bd Update kick.js
ID Support, Boolean Changes
2021-05-23 10:39:36 +02:00
DuAnti7423 cd17b0a6d7 Update help.js
ID Support, Boolean Changes
2021-05-23 10:39:31 +02:00
DuAnti7423 c5dd8823f6 Update close.js
ID Support, Boolean Changes
2021-05-23 10:39:24 +02:00
DuAnti7423 c27f16f753 Update botSay.js
ID Support, Boolean Changes
2021-05-23 10:39:18 +02:00
DuAnti7423 4842610229 Update blacklistUser.js
ID Support, Boolean Changes
2021-05-23 10:39:13 +02:00
DuAnti7423 bd33669dba Update ban.js
ID Support, Boolean Changes
2021-05-23 10:39:08 +02:00
DuAnti7423 e81684d322 Update announce.js
ID Support, Boolean Changes
2021-05-23 10:39:03 +02:00
DuAnti7423 8209dfa26c Update addUser.js
ID Support, Boolean Changes
2021-05-23 10:38:55 +02:00
DuAnti7423 0a82c62a4e boolean changes
workin on suggestion #67  from pat2381
2021-05-22 23:41:39 +02:00
DuAnti7423 4399dfe6b8 Fixxed a copy error
replaced reactionUser with user cause reactionUser was defined in the ticket command
2021-05-22 23:38:32 +02:00
DuAnti7423 defd0cc78c Merge branch 'master' of https://github.com/DuAnti7423/SupportBot 2021-05-22 23:13:25 +02:00
DuAnti7423 fd5fc8294c update help.js
Fixxed a bug with staffcommands show
2021-05-22 23:13:16 +02:00
ItzEmerald 10823b416c Update package.json 2021-05-14 15:46:23 +01:00
ItzEmerald 4490920ac6 Update index.js 2021-05-14 15:46:08 +01:00
ItzEmerald e0b8ad4a32 Update supportbot-config.yml 2021-05-14 15:45:49 +01:00
ItzEmerald e11063832b Merge pull request #65 from DuAnti7423/master
Blacklist Command
2021-05-12 15:35:56 +01:00
DuAnti7423 d598fdd753 updated help.js 2021-05-09 00:25:30 +02:00
DuAnti7423 157b93239a Added Logging to Blacklist 2021-05-08 23:55:57 +02:00
DuAnti7423 3d7058dccd Merge branch 'master' of https://github.com/DuAnti7423/SupportBot 2021-05-08 23:44:12 +02:00
DuAnti7423 1ec654d1ea Blacklist Command
Added Blacklist Command
Added configoption
2021-05-08 23:42:35 +02:00
ItzEmerald 8ce2d4269d Merge pull request #62 from DuAnti7423/master
Blacklist Role
2021-05-07 15:09:46 +01:00
DuAnti7423 49e8aa7869 Blacklist Role
Added Blacklist Role, via ID or Name
2021-04-17 22:15:59 +02:00
ItzEmerald 0827f55d43 Update guildMemberAdd.js 2021-03-31 01:21:34 +01:00
ItzEmerald ba6ff8c342 Update node.js.yml 2021-03-31 00:28:35 +01:00
ItzEmerald c0b7dc2737 Create node.js.yml 2021-03-31 00:27:10 +01:00
ItzEmerald ab6abd453b Delete node_modules directory 2021-03-30 20:20:33 +01:00
ItzEmerald 77921aca75 Update ready.js 2021-03-30 20:18:37 +01:00
ItzEmerald a8f91c4814 Update messageReactionAdd.js 2021-03-30 20:12:29 +01:00
ItzEmerald 7780fa792c Updated 2021-03-27 16:58:27 +00:00
ItzEmerald 41e4c376ec Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2021-03-27 16:22:37 +00:00
ItzEmerald 09aad61cfe SB-6.3-BETA 2
Added Moderation
Added Node Modules
2021-03-27 16:22:19 +00:00
ItzEmerald 31ac81428f Update ready.js 2021-03-24 20:56:57 +00:00
ItzEmerald 60daab82d2 Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2021-03-24 16:26:21 +00:00
ItzEmerald e7a0544004 Added Image to ReactionPanel 2021-03-24 16:25:16 +00:00
ItzEmerald ff0364bba8 Update supportbot-config_FR.yml 2021-03-17 16:04:41 +00:00
ItzEmerald a3b64be351 Update supportbot-config_ES.yml 2021-03-17 16:03:52 +00:00
ItzEmerald eae370e346 Update README.md 2021-03-17 14:36:11 +00:00
ItzEmerald 0d86b50a83 Update supportbot-config_ENG.yml 2021-03-17 14:35:45 +00:00
ItzEmerald 543b9028a3 Update supportbot-config_ES.yml 2021-03-17 14:35:15 +00:00
ItzEmerald 867f12d11f Update supportbot-config_FR.yml 2021-03-17 14:33:13 +00:00
ItzEmerald 462781ea6a Update supportbot-config_GER.yml 2021-03-17 14:31:37 +00:00
ItzEmerald c300ab6459 Update supportbot-config.yml 2021-03-11 18:57:27 +00:00
ItzEmerald 448d8f3007 Update ready.js 2021-03-11 18:55:33 +00:00
ItzEmerald faa53b0af1 Create supportbot-config_ENG.yml 2021-03-03 19:42:29 +00:00
ItzEmerald 8e53b8d9a6 Rename supportbot-config_GN.yml to supportbot-config_GER.yml 2021-03-03 19:41:36 +00:00
ItzEmerald 5678567baa Create supportbot-config_GN.yml 2021-03-03 19:37:39 +00:00
ItzEmerald 1c30f73bb1 Create supportbot-config_FR.yml 2021-03-03 18:36:02 +00:00
ItzEmerald bd3dbc3033 Rename supportbot-config.y_ES.yml to supportbot-config_ES.yml 2021-03-03 00:39:41 +00:00
ItzEmerald 3c312ab8d3 Create supportbot-config.y_ES.yml
Spanish
2021-03-03 00:39:20 +00:00
ItzEmerald 7284785db0 Delete eyeroe.md 2021-02-13 16:25:53 +00:00
ItzEmerald 4e543ea490 Update messageReactionAdd.js 2021-02-13 16:25:28 +00:00
ItzEmerald 4d3540126f Update eyeroe.md 2021-02-08 15:34:02 +00:00
ItzEmerald 6c29f0d792 Create eyeroe.md 2021-02-08 15:33:44 +00:00
wxip c85df7c40f Update ready.js 2021-02-05 21:39:16 +02:00
wxip 4f6ff575ce Quick Fix
Hopefully fixes `INVALID_TYPE` error on GuildMemberAdd event.
2021-02-05 21:26:21 +02:00
ItzEmerald aaf3890f71 Update guildMemberAdd.js 2021-02-05 16:15:51 +00:00
ItzEmerald 4f9837d991 Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2021-02-04 12:54:29 +00:00
ItzEmerald 2e2eb008c6 Language PRE-01 2021-02-04 12:54:19 +00:00
ItzEmerald fa7074bb59 Update messageReactionAdd.js 2021-02-02 17:56:13 +00:00
ItzEmerald 18a7def050 Delete package-lock.json 2021-02-02 17:55:00 +00:00
ItzEmerald 40e9cf5fe5 Update messageReactionAdd.js 2021-02-02 17:52:07 +00:00
ItzEmerald 6ce049a57e Update supportbot-config.yml 2021-02-02 17:26:31 +00:00
ItzEmerald d4c4eac710 Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2021-02-02 17:15:36 +00:00
ItzEmerald 28ec96e5e8 SB-6.1-PRE 2021-02-02 17:15:24 +00:00
ItzEmerald a20fe8eb06 Update ticketreaction.yml 2021-02-02 17:02:35 +00:00
ItzEmerald 7d1d9b881d Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2021-02-02 17:02:03 +00:00
ItzEmerald c116e6cbb0 SB-6.1-PRE 2021-02-02 17:01:58 +00:00
ItzEmerald a3ebf0a33b Delete .DS_Store 2021-02-02 16:19:13 +00:00
ItzEmerald ea6ef022e7 Delete .DS_Store 2021-02-02 16:19:02 +00:00
ItzEmerald 683e29196a Update README.md 2021-02-02 16:18:34 +00:00
ItzEmerald 71694d7082 Update README.md 2021-02-02 16:18:14 +00:00
ItzEmerald c5d5656fb6 Update supportbot-config.yml 2021-02-02 16:09:23 +00:00
ItzEmerald b1fdbee5f8 Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2021-02-02 16:08:10 +00:00
ItzEmerald 70405a1cd0 SB-6.1-PRE
Ticket Reaction Create
2021-02-02 16:07:28 +00:00
ItzEmerald 3d7e81169f Delete node_modules directory 2021-02-02 15:54:24 +00:00
ItzEmerald fa7c8c7219 Update supportbot-config.yml 2021-02-02 13:51:48 +00:00
ItzEmerald 358a25f522 SB-6.1-PRE 2021-02-02 13:50:53 +00:00
ItzEmerald 690169a409 Update LICENSE 2021-02-02 09:44:58 +00:00
ItzEmerald 0ec4cc5a51 Merge pull request #49 from 1tzemerald/add-license-1
Create LICENSE
2021-02-02 00:27:27 +00:00
ItzEmerald 0b7dbaf368 Create LICENSE 2021-02-02 00:27:04 +00:00
ItzEmerald 940c445182 Delete LICENSE 2021-02-01 23:19:47 +00:00
ItzEmerald d8a90c9aa5 Update LICENSE 2021-01-28 14:41:17 +00:00
ItzEmerald 89ee8fceca Merge pull request #47 from 1tzemerald/SupportBot
Support bot
2020-12-30 18:59:41 +00:00
ItzEmerald 68b55a2d33 Delete jo 2020-12-30 18:59:23 +00:00
ItzEmerald 8cc2e7747f Delete supportbot-crowdin.yml 2020-12-30 18:59:11 +00:00
ItzEmerald 4e6ac3a294 Update Crowdin configuration file 2020-12-30 18:57:19 +00:00
ItzEmerald 732e22ea7b Create jo 2020-12-30 18:55:57 +00:00
ItzEmerald ac0d9981f8 Delete supportbot-crowdin.yml 2020-12-30 18:49:11 +00:00
ItzEmerald 23f7310760 Create supportbot-crowdin.yml 2020-12-30 18:48:56 +00:00
ItzEmerald 02a9ce6bac Update messageReactionAdd.js 2020-12-24 16:35:33 +00:00
ItzEmerald 069665358f Update README.md 2020-12-24 15:32:42 +00:00
ItzEmerald 2cf6d69127 Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2020-12-24 15:19:50 +00:00
ItzEmerald 870435061d Updated 2020-12-24 15:19:02 +00:00
Kieran Davies 53ad906491 Create .gitignore 2020-12-18 14:26:16 +00:00
Kieran Davies 01399e343b Update messageReactionAdd.js 2020-12-18 13:59:36 +00:00
Kieran Davies a0bdec4c42 Update messageReactionAdd.js 2020-12-18 13:51:51 +00:00
Kieran Davies 914f2057d8 Update messageReactionAdd.js 2020-12-18 13:51:03 +00:00
Kieran Davies ed9b9dca79 Update messageReactionAdd.js 2020-12-18 13:46:16 +00:00
ItzEmerald f6d4383ff6 6.1 PRE-0.2 2020-12-18 13:37:56 +00:00
ItzEmerald b6ccf30ae4 Update supportbot-config.yml 2020-12-11 22:12:21 +00:00
ItzEmerald 48e44a0d38 Update supportbot-config.yml 2020-12-11 22:12:02 +00:00
ItzEmerald 735998b48c Delete messageReactionRemove.js 2020-12-11 22:11:25 +00:00
ItzEmerald d94eefddb7 Delete messageReactionAdd.js 2020-12-11 22:11:07 +00:00
ItzEmerald 5a65b4df17 Update ready.js 2020-12-11 21:45:10 +00:00
ItzEmerald 15a839a310 Update message.js 2020-12-11 21:44:56 +00:00
ItzEmerald 9730c61962 Update guildMemberRemove.js 2020-12-11 21:44:31 +00:00
ItzEmerald 1805c041a6 Update guildMemberRemove.js 2020-12-11 21:44:20 +00:00
ItzEmerald f0bde93d5f Update guildMemberAdd.js 2020-12-11 21:43:58 +00:00
ItzEmerald 1ed7412f92 Update index.js 2020-12-11 21:43:36 +00:00
ItzEmerald 1f5ea2d25b Update suggest.js 2020-12-11 21:42:34 +00:00
ItzEmerald ff4c8f0e85 Update removeUser.js 2020-12-11 21:42:20 +00:00
ItzEmerald 5c3dd878b8 Update ping.js 2020-12-11 21:42:04 +00:00
ItzEmerald 20f0d91c75 Update newticket.js 2020-12-11 21:41:36 +00:00
ItzEmerald 894e48756f Update links.js 2020-12-11 21:41:13 +00:00
ItzEmerald aef199b292 Update help.js 2020-12-11 21:39:23 +00:00
ItzEmerald 9b884cd941 Update close.js 2020-12-11 21:39:09 +00:00
ItzEmerald b22d813cee Update botSay.js 2020-12-11 21:38:45 +00:00
ItzEmerald 0981566cbe Update announce.js 2020-12-11 21:38:31 +00:00
ItzEmerald 0647aa435a Update addUser.js 2020-12-11 21:38:13 +00:00
ItzEmerald 58179f98e9 Update index.js 2020-12-11 13:45:00 +00:00
ItzEmerald 05fe62536b Update supportbot-config.yml 2020-12-11 13:44:46 +00:00
ItzEmerald 241d41e220 Update supportbot-config.yml 2020-12-11 13:43:53 +00:00
ItzEmerald 34d6134c24 Update supportbot-config.yml 2020-12-11 13:43:17 +00:00
ItzEmerald 702d1d1998 Add files via upload 2020-12-11 13:42:27 +00:00
ItzEmerald e43a5fd5a3 Delete unhandledRejection.js 2020-12-10 11:34:01 +00:00
ItzEmerald 4ed9a7b7f5 Create README.md 2020-12-10 11:33:38 +00:00
ItzEmerald d8ba76e395 Delete README.md 2020-12-10 11:32:00 +00:00
ItzEmerald 448004abca Delete SUMMARY.md 2020-12-10 11:31:50 +00:00
ItzEmerald 7b0db34fb1 Delete image.png 2020-12-10 11:30:46 +00:00
ItzEmerald a7e7d5b372 Delete mit-license.md 2020-12-10 11:30:19 +00:00
ItzEmerald 9768628e8b Delete terms-and-conditions.md 2020-12-10 11:30:10 +00:00
ItzEmerald 6c0328b2fd Delete README.md 2020-12-09 11:11:59 +00:00
ItzEmerald 6bf7d879e2 Delete hosting-provider.md 2020-12-09 11:11:50 +00:00
ItzEmerald f25490f1ad Delete ubuntu-installation.md 2020-12-09 11:11:43 +00:00
ItzEmerald 31eee78535 Delete README.md 2020-12-09 11:11:30 +00:00
ItzEmerald 4ed2aff1b7 Create unhandledRejection.js 2020-12-09 11:10:06 +00:00
ItzEmerald 6f05545be6 Delete changelog.md 2020-12-09 11:06:55 +00:00
ItzEmerald dfec8a0e4f Delete download.md 2020-12-09 11:06:33 +00:00
ItzEmerald 47b730111b Delete untitled.md 2020-12-09 11:06:26 +00:00
ItzEmerald 59ba27c36f Delete creating-your-own-addon.md 2020-12-09 11:06:17 +00:00
ItzEmerald b501164c38 Delete README.md 2020-12-09 11:06:08 +00:00
Itz Emerald 1b795087bf GitBook: [master] one page modified 2020-12-07 15:50:30 +00:00
Itz Emerald 7063edc10f GitBook: [master] one page modified 2020-12-07 15:50:11 +00:00
Itz Emerald c02abcb760 GitBook: [master] one page modified 2020-12-07 15:48:42 +00:00
Itz Emerald 2d1a20dcc6 GitBook: [master] one page modified 2020-12-07 15:48:27 +00:00
Itz Emerald 65156d4b20 GitBook: [master] 14 pages and one asset modified 2020-11-28 20:03:35 +00:00
ItzEmerald 951cedb190 Update LICENSE 2020-09-27 01:18:01 +01:00
ItzEmerald 11442f320d Update index.js 2020-09-27 01:14:37 +01:00
ItzEmerald 33df21f656 Update supportbot-config.yml 2020-09-27 01:13:56 +01:00
ItzEmerald 19ab208678 Merge pull request #40 from real2two/patch-1
Add "Delete Messages" options on the configuration file.
2020-09-27 01:06:29 +01:00
ItzEmerald 59b9651575 Merge pull request #42 from real2two/patch-3
Fix prefix on playing status.
2020-09-27 01:06:06 +01:00
ItzEmerald c1c1f1767a Merge pull request #44 from 1tzemerald/dependabot/npm_and_yarn/node-fetch-2.6.1
Bump node-fetch from 2.6.0 to 2.6.1
2020-09-27 01:05:42 +01:00
dependabot[bot] dda8a5180c Bump node-fetch from 2.6.0 to 2.6.1
Bumps [node-fetch](https://github.com/bitinn/node-fetch) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/bitinn/node-fetch/releases)
- [Changelog](https://github.com/node-fetch/node-fetch/blob/master/docs/CHANGELOG.md)
- [Commits](https://github.com/bitinn/node-fetch/compare/v2.6.0...v2.6.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-27 00:04:16 +00:00
ItzEmerald 34d558412f Update package.json 2020-09-27 01:02:51 +01:00
ItzEmerald be77ee1903 Update newticket.js 2020-09-27 01:00:54 +01:00
ItzEmerald fc571d6c73 Update README.md 2020-09-09 14:04:46 +01:00
Two 7ca5a97535 Update supportbot-config.yml 2020-08-02 10:25:12 -04:00
ItzEmerald 730239ba8d Merge branch 'master' of https://github.com/1tzemerald/SupportBot 2020-08-02 13:53:17 +01:00
ItzEmerald 9069e23188 GuildMemberRemove Fix 2020-08-02 13:53:14 +01:00
Two 366203fe21 Update addon-example.js 2020-07-27 10:45:25 -04:00
Two f23f15c972 Update announce.js 2020-07-27 10:44:06 -04:00
Two 8432290a70 Update botSay.js 2020-07-27 10:43:59 -04:00
Two ceddd503f8 Update close.js 2020-07-27 10:43:51 -04:00
Two 51ad35f72b Update help.js 2020-07-27 10:43:45 -04:00
Two 5631b24a14 Update links.js 2020-07-27 10:43:36 -04:00
Two f9da25643d Update newticket.js 2020-07-27 10:43:30 -04:00
Two 75bed3f5dd Update ping.js 2020-07-27 10:43:25 -04:00
Two d4576dfc21 Update removeUser.js 2020-07-27 10:43:17 -04:00
Two 31bad3c2e5 Update suggest.js 2020-07-27 10:43:11 -04:00
Two a3c0e81e04 Update addUser.js 2020-07-27 10:42:53 -04:00
Two b5cf721efb Update supportbot-config.yml 2020-07-27 10:42:39 -04:00
Two 95063398aa Update message.js 2020-07-27 10:39:25 -04:00
ItzEmerald 2da25c5566 Update README.md 2020-07-26 12:57:08 +01:00
ItzEmerald a72c1ab380 Merge pull request #39 from 1tzemerald/SupportBot-6.0
Support bot 6.0
2020-07-23 20:20:32 +01:00
ItzEmerald 87b9301e20 Update help.js 2020-07-23 20:16:32 +01:00
ItzEmerald d0a6bed727 Delete BotSay.js 2020-07-23 17:40:31 +01:00
ItzEmerald 29881fbf28 Merge branch 'SupportBot-6.0' of https://github.com/1tzemerald/SupportBot into SupportBot-6.0 2020-07-15 18:42:13 +01:00
ItzEmerald 4f05ad6546 SupportBot 6.0 BETA 1 2020-07-15 18:42:07 +01:00
doge 36fc9aae6c Update removeUser.js 2020-07-15 12:05:39 -04:00
doge fb3f87c685 Update addUser.js 2020-07-15 12:05:24 -04:00
doge a4c0eeb9b3 Update addUser.js 2020-07-15 11:57:32 -04:00
doge c64f249fd0 Update removeUser.js 2020-07-15 11:57:14 -04:00
doge 48b542293f Update addUser.js 2020-07-15 11:56:52 -04:00
doge 95e92970c9 Update removeUser.js 2020-07-15 11:44:17 -04:00
doge afdb16da07 Update addUser.js 2020-07-15 11:44:01 -04:00
doge 0a230cfab9 fix remove 2020-07-15 11:37:58 -04:00
doge c752a07ad0 fix adduser 2020-07-15 11:37:17 -04:00
ItzEmerald cc5a6b7317 Update BotSay.js 2020-07-15 02:20:05 +01:00
ItzEmerald d93991ad86 Merge branch 'SupportBot-6.0' of https://github.com/1tzemerald/SupportBot into SupportBot-6.0 2020-07-15 02:20:03 +01:00
ItzEmerald 5c98376a8c RolePerms Update 2020-07-15 02:16:15 +01:00
ItzEmerald 28699f0ff0 Updated 2020-07-14 19:42:46 +01:00
ItzEmerald e3576b17b1 Updated 2020-07-14 19:17:40 +01:00
ItzEmerald 17c49b2b03 Merge branch 'SupportBot-6.0' of https://github.com/1tzemerald/SupportBot into SupportBot-6.0 2020-07-11 13:19:19 +01:00
ItzEmerald dcc00d8d1f Update 2020-07-11 13:17:57 +01:00
ItzEmerald 05a772eee2 Create removeUser.js 2020-07-10 20:56:40 +01:00
ItzEmerald 59dd291dc6 Update addUser.js 2020-07-10 20:55:02 +01:00
ItzEmerald f0a791e354 Update README.md 2020-07-10 20:48:41 +01:00
ItzEmerald 94db6cff7b Create addUser.js 2020-07-10 18:55:03 +01:00
ItzEmerald 72a02bf6d2 Close.js updated 2020-07-04 00:54:34 +01:00
ItzEmerald cf99209887 Close Ticket 2020-06-24 09:50:14 +01:00
ItzEmerald ff7ed49905 guildMemberRemove added 2020-06-21 15:56:16 +01:00
ItzEmerald ecd17a51df guildMemberAdd Updated 2020-06-21 12:36:48 +01:00
ItzEmerald 63b08f8c12 guildMemberAdd Updated 2020-06-20 16:10:27 +01:00
ItzEmerald 3077772aff Merge branch 'SupportBot-6.0' of https://github.com/1tzemerald/SupportBot into SupportBot-6.0 2020-06-20 15:46:18 +01:00
ItzEmerald f40aa9ed14 push 2020-06-20 13:02:46 +01:00
ItzEmerald 4ae6dc3a69 Update guildMemberAdd.js 2020-06-20 00:18:17 +01:00
ItzEmerald bdb8389d8d Merge branch 'SupportBot-6.0' of https://github.com/1tzemerald/SupportBot into SupportBot-6.0 2020-06-20 00:11:02 +01:00
ItzEmerald f7254c7ec7 Push 2020-06-20 00:10:38 +01:00
ItzEmerald 3701156362 Merge pull request #35 from real2two/patch-20
Update newticket.js
2020-06-19 23:42:03 +01:00
Two 575d25f5ad Update newticket.js 2020-06-16 21:54:40 -04:00
Two 698b82c7d3 Update newticket.js 2020-06-15 21:29:31 -04:00
ItzEmerald eaa9da6d2e 6.0-Dev Edition 2020-06-15 23:58:37 +01:00
ItzEmerald ca7294a0e3 Delete supportbot-config.yml 2020-06-15 23:47:10 +01:00
ItzEmerald 5281ffce60 overwrite Permissions to updateOverwrite 2020-06-12 23:30:34 +01:00
ItzEmerald 2f84fd3b33 overwritePermissions to updateOverwrite 2020-06-12 23:30:01 +01:00
ItzEmerald bef270da74 Update LICENSE 2020-05-19 00:40:47 +01:00
ItzEmerald bdf839ffca Merge pull request #31 from 1tzemerald/add-license-1
Create LICENSE
2020-05-10 00:42:44 +01:00
ItzEmerald b1b2ead0d8 Create LICENSE 2020-05-10 00:42:31 +01:00
ItzEmerald 0b74473c64 Delete LICENSE 2020-05-10 00:40:43 +01:00
lynxgian 2fb189cf5c updated Readme.md 2020-05-07 19:32:16 -04:00
lynxgian 24a29639e9 Update README.md 2020-05-07 19:30:53 -04:00
ItzEmerald 50258dc359 Update index.js 2020-04-21 20:20:42 +01:00
ItzEmerald d9c6b7698f Update index.js 2020-04-21 19:37:28 +01:00
ItzEmerald f042f34ae2 Update index.js 2020-04-21 19:24:15 +01:00
ItzEmerald c087edc14f Update suggest.js 2020-04-18 22:49:03 +01:00
ItzEmerald d96ccc3265 Update add.js 2020-04-17 02:59:25 +01:00
ItzEmerald 018ef19efd Update remove.js 2020-04-17 02:56:10 +01:00
ItzEmerald 159317a15a Update remove.js 2020-04-17 02:55:25 +01:00
ItzEmerald 34583c96bf Update add.js 2020-04-17 02:54:20 +01:00
ItzEmerald 887d3d3ab4 Update ticket.js 2020-04-17 02:48:38 +01:00
ItzEmerald f2de39de33 Updated 5.2 2020-04-16 16:43:16 +01:00
ItzEmerald 8173c31a60 Update supportbot-config.yml 2020-04-15 16:18:31 +01:00
ItzEmerald 80a2871741 Update ping.js 2020-04-15 15:08:13 +01:00
ItzEmerald 3b37e9c420 Updated say.js
Updated RichEmbed to MessageEmbed
2020-04-15 15:07:20 +01:00
ItzEmerald 730ba561bc Update index.js 2020-04-15 15:05:56 +01:00
ItzEmerald 4d7ddc9d6f Update ping.js 2020-04-14 22:45:51 +01:00
ItzEmerald 680f71a602 Merge pull request #15 from real2two/patch-14
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:53:50 +01:00
ItzEmerald 170a7b50b9 Merge pull request #16 from real2two/patch-13
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:53:14 +01:00
ItzEmerald 805c48c1bb Merge pull request #17 from real2two/patch-15
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:53:03 +01:00
ItzEmerald 7d92ed51bb Merge pull request #18 from real2two/patch-16
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:52:49 +01:00
ItzEmerald f849897f29 Merge pull request #19 from real2two/patch-10
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:52:32 +01:00
ItzEmerald 75e0f1ad1e Merge pull request #20 from real2two/patch-11
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:52:15 +01:00
ItzEmerald 2f015aff2b Merge pull request #21 from real2two/patch-12
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:52:03 +01:00
ItzEmerald bb322adb2f Merge pull request #22 from real2two/patch-7
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:51:14 +01:00
ItzEmerald 6ee0981885 Merge pull request #23 from real2two/patch-8
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:51:02 +01:00
ItzEmerald 877fb0f201 Merge pull request #24 from real2two/patch-9
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:50:39 +01:00
ItzEmerald ded4b9094a Merge pull request #25 from real2two/patch-4
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:50:07 +01:00
ItzEmerald 36ab931998 Merge pull request #26 from real2two/patch-5
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:49:54 +01:00
ItzEmerald 20baddfcca Merge pull request #27 from real2two/patch-6
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:49:36 +01:00
ItzEmerald d180c147db Merge pull request #28 from real2two/patch-1
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:49:20 +01:00
ItzEmerald 86fa91cc52 Merge pull request #29 from real2two/patch-2
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:48:53 +01:00
ItzEmerald b8c12a0307 Merge pull request #30 from real2two/patch-3
Changed all RichEmbed to MessageEmbed.
2020-04-08 17:48:24 +01:00
Two eff2f9c195 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:15:27 -04:00
Two 8a8597afef Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:15:23 -04:00
Two 067e619488 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:15:17 -04:00
Two f7f8bc8c55 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:15:07 -04:00
Two 0c71fd7e59 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:15:06 -04:00
Two 5b2bd21c06 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:14:57 -04:00
Two 9ac68f68e2 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:14:42 -04:00
Two aa32f00b0d Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:14:35 -04:00
Two bd5f5dffad Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:14:28 -04:00
Two 645ad63f4f Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:14:23 -04:00
Two c83c6afecc Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:14:18 -04:00
Two 689a191d4c Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:14:10 -04:00
Two 5767ba0de5 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:13:57 -04:00
Two cdf78f2598 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:13:51 -04:00
Two d23f46ad9e Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:11:48 -04:00
Two 8453fa5007 Changed all RichEmbed to MessageEmbed.
This is order to add discord.js v12 support.
2020-04-06 18:10:32 -04:00
ItzEmerald 0bd990a9e3 updated index.js 2020-03-31 09:57:39 +01:00
ItzEmerald 9c8ee1471b updated version number 2020-03-31 09:56:09 +01:00
ItzEmerald 87262e82a3 Update forceclose.js 2020-03-31 00:15:10 +01:00
ItzEmerald 953db88ed2 Update close.js 2020-03-31 00:14:47 +01:00
doge a44bb949eb Merge pull request #13 from 1tzemerald/version-5.2
Update README.md
2020-03-12 20:28:49 -04:00
doge 9d11369059 Update README.md 2020-03-12 20:28:02 -04:00
ItzEmerald 8c92f39349 Delete package-lock.json 2020-02-23 13:13:31 +00:00
ItzEmerald bce9d733d3 Update package.json 2020-02-23 13:13:12 +00:00
ItzEmerald 525c216401 Update suggest.js 2020-02-09 13:14:10 +00:00
ItzEmerald c145e9f11d Update README.md 2020-02-04 10:42:59 +00:00
ItzEmerald 53700003f1 Update README.md 2020-02-04 10:41:12 +00:00
ItzEmerald 03094a4c35 Update README.md 2020-02-04 10:39:09 +00:00
ItzEmerald 934ebed24f Update ticket.js 2020-02-02 12:06:04 +00:00
ItzEmerald 9f469e1637 Update supportbot-config.yml 2020-01-30 21:03:17 +00:00
ItzEmerald 77fe39774d Update close.js 2020-01-30 21:02:35 +00:00
ItzEmerald 186d688ec8 Delete setup.js 2020-01-30 20:56:37 +00:00
ItzEmerald de3181b5c7 Update tickets.json 2020-01-29 21:43:06 +00:00
ItzEmerald 6d53234f7a Update supportbot-config.yml 2020-01-29 21:42:37 +00:00
ItzEmerald b0d3673f4b Update close.js 2020-01-29 21:41:56 +00:00
ItzEmerald e40083f585 Add files via upload 2020-01-29 21:40:44 +00:00
ItzEmerald f7b702fa29 Update ticket.js 2020-01-29 20:06:28 +00:00
ItzEmerald 5089a19a56 Update TicketNumber.js 2020-01-29 20:05:34 +00:00
ItzEmerald d77e9842aa Update TicketNumber.js 2020-01-29 19:59:44 +00:00
ItzEmerald 88ae76a25a Update ticket.js 2020-01-29 19:59:14 +00:00
ItzEmerald 9b0b0d10a1 Update ticket.js 2020-01-29 19:08:00 +00:00
ItzEmerald 68340a8b02 Create tickets.json 2020-01-29 18:59:22 +00:00
ItzEmerald d14f0966d5 Update TicketNumber.js 2020-01-29 18:58:58 +00:00
ItzEmerald e4b83f28c4 Update TicketNumber.js 2020-01-29 18:57:22 +00:00
ItzEmerald 1925deec90 Create TicketNumber.js 2020-01-29 18:55:32 +00:00
ItzEmerald e9ba960b29 Delete utils 2020-01-29 18:52:23 +00:00
ItzEmerald 821f0f5b60 Create utils 2020-01-29 18:52:13 +00:00
ItzEmerald 8fa8915673 Update README.md 2020-01-29 18:34:02 +00:00
ItzEmerald 5405aa469e Update README.md 2020-01-29 18:33:42 +00:00
ItzEmerald cfa50b4e8c Update README.md 2020-01-29 18:33:17 +00:00
ItzEmerald c5014baeb4 Update README.md 2020-01-29 18:32:09 +00:00
ItzEmerald c14f120ec2 Update README.md 2020-01-29 18:27:10 +00:00
ItzEmerald b72fcf39da Update help.js 2020-01-26 23:53:53 +00:00
ItzEmerald 6da3b0cba0 Update README.md 2020-01-26 22:47:39 +00:00
ItzEmerald 752387a02d Update README.md 2020-01-26 22:47:05 +00:00
ItzEmerald 32d3e1b957 Update README.md 2020-01-26 22:46:47 +00:00
ItzEmerald a029dff202 Update README.md 2020-01-26 22:44:45 +00:00
ItzEmerald 89e1b85a56 Delete settings.json 2020-01-26 22:38:25 +00:00
ItzEmerald 4350eb709c Delete old.json 2020-01-26 22:38:13 +00:00
ItzEmerald 2b1b6e916e Updated SupportBot
Pre-Release - 5.1.0
2020-01-26 22:35:45 +00:00
ItzEmerald 3f6f9045bd Update announce.js 2020-01-25 16:59:35 +00:00
ItzEmerald 8294496faa Update add.js 2020-01-25 16:56:56 +00:00
ItzEmerald 9f58118ecd Update supportbot-config.yml 2020-01-25 16:51:30 +00:00
ItzEmerald 3ba9d42587 Delete terminal.sh 2020-01-25 14:20:08 +00:00
ItzEmerald e496a5f3d0 Delete terminal.bat 2020-01-25 14:19:45 +00:00
ItzEmerald 04e2bb29b0 Update supportbot-config.yml 2020-01-25 14:09:40 +00:00
ItzEmerald 131a7a015c Update supportbot-config.yml 2020-01-25 14:03:20 +00:00
ItzEmerald 6f430f5f14 Create supportbot-config.yml 2020-01-25 13:57:23 +00:00
ItzEmerald 0154c106ef updated index.js 2020-01-25 13:55:33 +00:00
ItzEmerald 04de6e6fc0 Update index.js 2020-01-25 13:48:02 +00:00
ItzEmerald 2b17e5f842 Update index.js 2020-01-25 13:42:44 +00:00
ItzEmerald 0dc8c1e68a updated announce.js
cleaned up the code + updated the command executed message
2020-01-25 13:37:25 +00:00
ItzEmerald 67b291eb09 Update add.js 2020-01-25 13:30:13 +00:00
ItzEmerald d7865a5a4e Update add.js 2020-01-25 13:28:01 +00:00
ItzEmerald d1809587a8 Updated add.js
Cleaned and updated the code within the file alongside the console.log for executing the command.
2020-01-25 13:20:44 +00:00
ItzEmerald 64ec4dfd53 Update LICENSE 2020-01-22 22:44:36 +00:00
ItzEmerald 6e07d21a3d Create someaddon.json 2020-01-22 22:42:10 +00:00
ItzEmerald 018e47bd53 Update default-addon.js 2020-01-22 22:41:33 +00:00
ItzEmerald 065a2e9347 Update help.js 2020-01-05 21:37:08 +00:00
ItzEmerald b53307c7fd Update ticket.js 2020-01-05 16:33:31 +00:00
ItzEmerald e4cc0d960e Update ticket.js 2020-01-05 15:33:23 +00:00
ItzEmerald 3336824df7 Update close.js 2020-01-05 15:22:50 +00:00
TommyTran732 166173d53c Update entrypoint.sh 2020-01-03 04:40:13 -05:00
TommyTran732 45da2d7924 Create entrypoint.sh 2020-01-02 11:51:19 -05:00
TommyTran732 af14a65ef5 Create Dockerfile 2020-01-02 11:50:14 -05:00
ItzEmerald 9049a4b9ad Update rename.js 2019-12-11 18:17:00 +00:00
ItzEmerald 2f4873ab9a Update settings.json 2019-12-07 13:08:34 +00:00
ItzEmerald 0fb25d71dc Update rename.js 2019-12-03 23:24:43 +00:00
ItzEmerald 34b6901434 Update remove.js 2019-12-03 23:24:22 +00:00
ItzEmerald a90b43aba3 Update add.js 2019-12-03 23:24:01 +00:00
ItzEmerald 0ac5c2c300 Update forceclose.js 2019-12-03 23:23:33 +00:00
ItzEmerald 6290fee7bd Update close.js 2019-12-03 23:22:54 +00:00
ItzEmerald 320992f3fa Update ticket.js 2019-12-03 23:17:47 +00:00
ItzEmerald bd714f0c93 Update ticket.js 2019-12-03 23:15:52 +00:00
ItzEmerald 45d3867c1f Update ticket.js 2019-12-03 23:13:56 +00:00
ItzEmerald deafa66565 Update settings.json 2019-12-03 23:13:02 +00:00
ItzEmerald ce8fcdaa7a Update settings.json 2019-12-03 23:11:58 +00:00
ItzEmerald b83965c144 Update ticket.js 2019-12-03 23:10:41 +00:00
ItzEmerald 2505a8cab8 Created MIT License 2019-12-03 21:41:34 +00:00
ItzEmerald 21e598bb31 Delete LICENSE 2019-12-03 21:39:11 +00:00
ItzEmerald ee2f3c0dfc Created GNU License 2019-12-03 21:32:00 +00:00
ItzEmerald 197bda14b8 Update rename.js 2019-11-29 22:34:57 +00:00
ItzEmerald d416c2e421 Update README.md 2019-11-18 16:12:28 +00:00
ItzEmerald 6bc2e669e4 Update README.md 2019-11-18 16:12:13 +00:00
ItzEmerald 9accb65256 Update README.md 2019-11-18 16:11:23 +00:00
ItzEmerald d9212584ca Update README.md 2019-11-18 16:10:19 +00:00
ItzEmerald 1a7effb422 Update README.md 2019-11-18 16:09:37 +00:00
ItzEmerald 1480435f15 Update index.js 2019-11-18 15:53:02 +00:00
ItzEmerald 64c6a97657 Update index.js 2019-11-18 15:49:36 +00:00
ItzEmerald 7ae2ff9837 Update settings.json 2019-11-18 15:49:21 +00:00
ItzEmerald f19dec405d Updated settings.json 2019-11-18 15:29:33 +00:00
ItzEmerald bbd4e9d162 Update ticket.js 2019-11-17 22:24:05 +00:00
ItzEmerald 58deff0869 Updated close.js
Added ticket transcripts
2019-11-17 21:27:13 +00:00
ItzEmerald 628632f43e Update forceclose.js 2019-11-15 21:54:32 +00:00
ItzEmerald 436586117c Update close.js 2019-11-15 21:53:39 +00:00
ItzEmerald 7234ec728c Update close.js 2019-11-15 20:43:29 +00:00
ItzEmerald fc4d68be39 Update forceclose.js 2019-11-15 20:31:58 +00:00
ItzEmerald bcf2df4bff Update close.js 2019-11-15 20:27:10 +00:00
ItzEmerald 70a469a951 Update settings.json
Added settings for transcripts
2019-11-15 20:25:43 +00:00
ItzEmerald 981995cb32 Merge pull request #7 from iJosGCS/patch-1
Update close.js
2019-11-15 20:00:14 +00:00
Joseph Gonzalo 406c7d368b Update close.js 2019-11-15 13:58:24 -06:00
ItzEmerald 5c266d8b8d Update README.md 2019-11-01 20:27:24 +00:00
Joseph Gonzalo 4f5b94a491 Update README.md 2019-10-17 17:08:13 -06:00
ItzEmerald 284c83cc1d Update help.js 2019-09-28 17:30:57 +01:00
RedstoneGamez 23f06af202 Update README.md 2019-09-07 14:59:59 -03:00
Emerald 82be317595 Updated ticket.js
Support v4.2
2019-08-17 00:00:48 +01:00
ItzEmerald a43c76529f Delete temp.js 2019-08-16 23:54:13 +01:00
Emerald 26065c2a5c Merge remote-tracking branch 'origin/master' 2019-08-16 23:52:15 +01:00
ItzEmerald 6b1dfe01cf 4.2 2019-08-16 23:51:18 +01:00
ItzEmerald 72443fb7c6 Update README.md 2019-08-10 11:52:08 +01:00
ItzEmerald 28cad23746 Update README.md 2019-08-10 11:51:02 +01:00
Joseph Gonzalo ebc0d4b137 Update settings.json 2019-08-04 20:44:02 -06:00
Joseph Gonzalo 4b198acda7 Update README.md 2019-08-04 20:43:22 -06:00
Joseph Gonzalo b84c3c75a5 Bug fixes 2019-08-04 20:42:35 -06:00
ItzEmerald de0ec97d9b Update 4.0.1
Bug Fixes:

Announcements displaying “undefined” above the announcement message instead of a role.

Tickets not allowing users to talk
2019-08-04 00:12:49 +01:00
ItzEmerald a8302cd276 Update help.js 2019-07-25 00:25:40 +01:00
ItzEmerald 35d8d77f15 Update help.js 2019-07-25 00:25:23 +01:00
ItzEmerald f853394d9d Update settings.json 2019-07-25 00:23:47 +01:00
Joseph Gonzalo 500c01dd1f Add files via upload 2019-07-24 16:52:30 -06:00
Joseph Gonzalo 71bfccfb0c Update README.md 2019-07-24 16:51:11 -06:00
Joseph Gonzalo 71e1b64818 Update README.md 2019-07-24 16:49:51 -06:00
Joseph Gonzalo 2118a00a36 Add files via upload 2019-07-24 16:47:57 -06:00
Joseph Gonzalo 1c69c21f21 Update package.json 2019-07-24 16:47:31 -06:00
Joseph Gonzalo 31c76fc1c0 Update index.js 2019-07-24 16:45:29 -06:00
ItzEmerald d31cdf8af2 Update default-addon.js 2019-06-25 15:08:40 +01:00
ItzEmerald 29b5fd2c6f Create default-addon.js 2019-06-25 14:38:11 +01:00
ItzEmerald aa32525f8c Delete default-addon.js 2019-06-25 14:35:14 +01:00
ItzEmerald f6a67a3373 Update default-addon.js 2019-06-24 19:09:32 +01:00
Joseph Gonzalo 4f78858641 Update README.md 2019-06-19 22:56:04 -06:00
Joseph Gonzalo c7e70bdaf8 Update README.md 2019-06-19 22:37:22 -06:00
Joseph Gonzalo 5479e0957b Update README.md 2019-06-19 22:36:58 -06:00
Joseph Gonzalo 8500554cfb Update README.md 2019-06-19 22:25:27 -06:00
Joseph Gonzalo 368727cece Update settings.json 2019-06-19 22:24:51 -06:00
Joseph Gonzalo 742faf3805 Update README.md 2019-06-19 22:24:37 -06:00
Joseph Gonzalo 7d08cbed84 Update settings.json 2019-06-19 14:28:01 -06:00
Joseph Gonzalo f413bb5f3c Update suggest.js
Better look for Suggestions
2019-06-19 14:27:24 -06:00
Joseph Gonzalo 2b3edc7b45 Update README.md 2019-06-19 13:24:08 -06:00
ItzEmerald 83f982f8d2 Update help.js 2019-06-19 20:23:55 +01:00
Joseph Gonzalo 890a1d41b1 Update README.md
Help section & contributors added!
2019-06-19 13:23:37 -06:00
Joseph Gonzalo 996a2c26ca Update index.js
Replace ${bot.user.username} to ${bot.user.id} in line 44.
2019-06-19 12:58:55 -06:00
Joseph Gonzalo fadf042101 Update package.json 2019-06-19 12:57:57 -06:00
ItzEmerald a819078d7f Merge pull request #4 from iJosGCS/patch-1
Update README.md
2019-06-19 19:30:56 +01:00
ItzEmerald 2c9b4c663d Merge pull request #5 from iJosGCS/patch-2
Update index.js
2019-06-19 19:30:44 +01:00
Joseph Gonzalo 22e6803e14 Update index.js 2019-06-19 12:21:41 -06:00
Joseph Gonzalo 61cecb112f Update README.md 2019-06-19 12:19:48 -06:00
ItzEmerald 0c18aff34d Update settings.json 2019-06-18 17:25:58 +01:00
RedsoneGamez 599649a97f Update 'links' command and it's settings 2019-06-18 13:09:00 -03:00
ItzEmerald 45194b82da Update README.md 2019-06-18 17:01:19 +01:00
ItzEmerald 570d0985d3 Update README.md 2019-06-18 16:23:13 +01:00
ItzEmerald 5eae625b28 Update settings.json
Added the option to change the auto role.
2019-06-18 16:22:02 +01:00
ItzEmerald fcc5460ff2 Update index.js 2019-06-18 16:13:51 +01:00
ItzEmerald 07d45e50de Updated index.js
Added Auto Join Role
2019-06-18 16:13:38 +01:00
ItzEmerald 73ea5708e3 Merge pull request #3 from iJosGCS/patch-5
Update README.md
2019-06-15 12:44:22 +01:00
ItzEmerald 5595f80e27 Merge pull request #2 from iJosGCS/patch-3
Create report.js
2019-06-15 12:44:06 +01:00
ItzEmerald 48a54a9a65 Update settings.json 2019-06-13 18:12:46 +01:00
ItzEmerald 78cc348639 Update settings.json 2019-06-13 18:12:03 +01:00
ItzEmerald e7ca70b676 Merge pull request #1 from iJosGCS/patch-1
Update settings.json
2019-06-13 18:11:08 +01:00
Joseph Gonzalo 59332e3a1c Update README.md 2019-06-11 17:56:47 -06:00
Joseph Gonzalo a6f2491ebc Create report.js 2019-06-11 17:30:57 -06:00
Joseph Gonzalo 9d07fddc65 Update settings.json 2019-06-11 17:28:49 -06:00
ItzEmerald eba7331a95 Update README.md 2019-06-10 20:20:37 +01:00
ItzEmerald ec215a62ae Update README.md 2019-06-10 20:19:51 +01:00
ItzEmerald 23797115b7 Update README.md 2019-06-10 20:17:50 +01:00
ItzEmerald 304b83b5bd Update README.md 2019-06-10 20:16:41 +01:00
ItzEmerald 8c5f6d27fe Update README.md 2019-06-10 20:05:57 +01:00
ItzEmerald 350630a408 Update README.md 2019-06-10 19:59:51 +01:00
ItzEmerald 242db5cb88 Set theme jekyll-theme-cayman 2019-06-10 01:30:00 +01:00
ItzEmerald 60d856f0a9 Delete feature_request.md 2019-06-10 01:15:23 +01:00
ItzEmerald 9d6b75b37a Update issue templates 2019-06-10 01:10:19 +01:00
ItzEmerald 01b3e96f74 Update README.md 2019-06-10 00:41:18 +01:00
ItzEmerald 4f1f4b691a Create README.md 2019-06-10 00:36:26 +01:00
ItzEmerald 1a0bfec6ef Delete start.bat 2019-06-10 00:21:03 +01:00
ItzEmerald 07ad994efc Update settings.json 2019-06-10 00:20:43 +01:00
ItzEmerald 225b48c595 Update issue templates 2019-06-10 00:17:07 +01:00
ItzEmerald 5e5735bf25 Initial commit 2019-06-09 23:52:41 +01:00
131 changed files with 22317 additions and 2 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
Configs/supportbot.yml filter=supportbot-token
Configs/api.yml filter=api-secrets
+8
View File
@@ -0,0 +1,8 @@
node_modules
dashboard/node_modules
# Local git hooks + secret-stripping scripts (run npm run git:setup)
scripts/
# Local secrets — use *.example as templates
Configs/api.yml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Emerald Services
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-2
View File
@@ -1,2 +0,0 @@
# SupportBot-Dashboard
Source code for the SupportBot Dashboard
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SupportBot Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4081
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "supportbot-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.0",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"emoji-picker-element": "^1.26.0",
"lucide-react": "^0.475.0",
"postcss": "^8.4.49",
"react": "^19.0.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+147
View File
@@ -0,0 +1,147 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import Layout from "./components/Layout";
import { useAuth } from "./context/AuthContext";
import { useSetup } from "./context/SetupContext";
import Configs from "./pages/Configs";
import Login from "./pages/Login";
import Setup from "./pages/Setup";
import { HomeRedirect } from "./components/home-redirect";
import { RequirePermission } from "./components/require-permission";
import Addons from "./pages/Addons";
import { AddonBuilder } from "@/pages/AddonBuilder";
import { DashboardSettings } from "@/pages/DashboardSettings";
import Settings from "./pages/Settings";
import SystemDetails from "./pages/SystemDetails";
import Logs from "./pages/Logs";
import Transcripts from "./pages/Transcripts";
import Tickets from "./pages/Tickets";
import Users from "./pages/Users";
import { Groups } from "./pages/Groups";
function Protected({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="flex min-h-svh items-center justify-center bg-background text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
const { complete, loading } = useSetup();
const location = useLocation();
if (loading) {
return (
<div className="flex min-h-svh items-center justify-center bg-background text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!complete) {
return (
<Routes>
<Route path="/setup" element={<Setup />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
);
}
if (location.pathname === "/setup") {
return <Navigate to="/login" replace />;
}
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<Protected>
<Layout />
</Protected>
}
>
<Route index element={<HomeRedirect />} />
<Route
path="system"
element={
<RequirePermission permission="overview">
<SystemDetails />
</RequirePermission>
}
/>
<Route
path="settings"
element={
<RequirePermission permission="settings.view">
<Settings />
</RequirePermission>
}
/>
<Route path="configs" element={<Configs />} />
<Route
path="logs"
element={
<RequirePermission permission="logs">
<Logs />
</RequirePermission>
}
/>
<Route
path="tickets"
element={
<RequirePermission permission="transcripts">
<Tickets />
</RequirePermission>
}
/>
<Route
path="transcripts"
element={
<RequirePermission permission="transcripts">
<Transcripts />
</RequirePermission>
}
/>
<Route
path="users"
element={
<RequirePermission permission="users.view">
<Users />
</RequirePermission>
}
/>
<Route
path="groups"
element={
<RequirePermission permission="users.view">
<Groups />
</RequirePermission>
}
/>
<Route
path="addons"
element={
<RequirePermission permission="settings.view">
<Addons />
</RequirePermission>
}
/>
<Route
path="addons/builder"
element={
<RequirePermission permission="settings.update">
<AddonBuilder />
</RequirePermission>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+740
View File
@@ -0,0 +1,740 @@
import type {
DashboardGroup,
DashboardPermissions,
DashboardRole,
DashboardUser,
StoredDashboardUser,
} from "@/lib/permissions";
export type { DashboardUser, DashboardPermissions, StoredDashboardUser, DashboardRole, DashboardGroup };
const REQUEST_TIMEOUT_MS = 25_000;
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = new Headers(options.headers);
if (!headers.has("Content-Type") && options.body) {
headers.set("Content-Type", "application/json");
}
const impersonatedGroup = typeof window !== "undefined" ? sessionStorage.getItem("nexus_impersonate_group") : null;
if (impersonatedGroup && !headers.has("X-Impersonate-Group")) {
headers.set("X-Impersonate-Group", impersonatedGroup);
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(path, {
...options,
headers,
credentials: "include",
signal: controller.signal,
});
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error(
"Request timed out. The API may still be starting or the bot is restarting — try again.",
);
}
throw new Error(
err instanceof Error
? err.message
: "Could not reach the API. Is the bot process running?",
);
} finally {
window.clearTimeout(timeout);
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || `Request failed (${res.status})`);
}
return body as T;
}
export type LogType = "Output" | "Warn" | "Error";
export interface LogEntry {
type: LogType;
timestamp: string | null;
message: string;
}
export interface LogsPayload {
entries: LogEntry[];
cursor: Record<string, number>;
}
export interface BotRestartResult {
success: boolean;
error?: string;
commands?: number;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
restartRequired?: boolean;
reloaded?: string[];
botRestart?: BotRestartResult;
}
export interface CatalogAddon {
id: string;
name: string;
slug?: string;
description: string;
repositoryUrl: string;
price?: number;
is_external?: number;
external_store?: string;
jsFiles: string[];
configFiles: string[];
fileCount?: number;
installed?: boolean;
}
export interface UpdateCheckResult {
id: string;
name: string;
current: string;
latest: string;
updateAvailable: boolean;
repository: string;
branch: string;
zipUrl: string;
}
export type NotificationType = "bot" | "update" | "info" | "error";
export interface DashboardNotification {
id: string;
type: NotificationType;
title: string;
message: string;
href: string | null;
createdAt: number;
read: boolean;
}
export interface NotificationsPayload {
items: DashboardNotification[];
unreadCount: number;
}
export type ModuleSetupStatus = "todo" | "progress" | "done";
export interface ModuleStatusInfo {
status: ModuleSetupStatus;
subtitle: string;
passed?: number;
total?: number;
}
export interface GuildSummary {
id: string;
name: string;
memberCount: number | null;
icon: string | null;
}
export type GuildHealthStatus =
| "ok"
| "missing_config"
| "not_in_guild"
| "extra_guilds"
| "offline";
export interface GuildHealth {
status: GuildHealthStatus;
configuredGuildId: string | null;
message: string | null;
configuredGuild: GuildSummary | null;
extraGuilds: GuildSummary[];
}
export type DashboardAlertSeverity = "error" | "warning" | "info";
export interface DashboardAlertPayload {
id: string;
severity: DashboardAlertSeverity;
category: string;
title: string;
message: string;
action?: { label: string; href: string };
meta?: Record<string, unknown>;
}
export interface GuildEmojiResource {
id: string;
name: string;
animated: boolean;
url: string | null;
}
export interface GuildResources {
guildId: string;
guildName: string;
guildIcon?: string | null;
roles: { id: string; name: string; color: string | null }[];
channels: { id: string; name: string; type: number }[];
categories: { id: string; name: string }[];
emojis: GuildEmojiResource[];
}
export interface BotStats {
bot: {
username: string;
id: string;
avatar: string;
inviteUrl?: string;
version: string;
ping: number;
uptime: number;
};
servers: number;
users: number;
tickets: { total: number; open: number; closed: number };
modules?: Record<string, ModuleStatusInfo>;
hosting: {
ram_used: string;
ram_total: string;
ram_percent: number;
cpu_load: string;
uptime: string;
};
}
export function maskConfigSecrets(content: string, filename: string): string {
if (filename !== "supportbot") return content;
return content.replace(
/^(\s*Token:\s*)["'][^"']*["']/m,
'$1"BOT_TOKEN"',
);
}
export const api = {
me: () => request<ApiResponse<DashboardUser>>("/api/auth/me"),
logout: () =>
request<ApiResponse<unknown>>("/api/auth/logout", { method: "POST" }),
health: () =>
request<
ApiResponse<{
dashboard: boolean;
botReady: boolean;
oauthEnabled?: boolean;
}>
>("/api/health"),
stats: () => request<ApiResponse<BotStats>>("/api/stats"),
getAlerts: () => request<ApiResponse<DashboardAlertPayload[]>>("/api/alerts"),
getGuildStatus: () =>
request<ApiResponse<GuildHealth>>("/api/guild/status"),
getGuildResources: (refresh = false) =>
request<ApiResponse<GuildResources>>(
`/api/guild/resources${refresh ? "?refresh=1" : ""}`,
),
leaveGuild: (guildId: string) =>
request<ApiResponse<GuildHealth>>(`/api/guild/${guildId}/leave`, {
method: "POST",
}),
listConfigs: () =>
[
"supportbot",
"ticket-panel",
"commands",
"messages",
"supportbot-ai",
] as const,
getConfigRaw: (file: string) =>
request<ApiResponse<string>>(`/api/configs/${file}`),
saveConfigRaw: (filename: string, content: string) =>
request<ApiResponse<unknown>>("/api/configs/raw", {
method: "PUT",
body: JSON.stringify({ filename, content }),
}),
getConfigJson: (file: string) =>
request<ApiResponse<Record<string, unknown>>>(`/api/configs/json/${file}`),
updateConfigFields: (updates: Record<string, unknown>) =>
request<ApiResponse<unknown>>("/api/configs/update-fields/multi", {
method: "POST",
body: JSON.stringify(updates),
}),
checkForUpdates: () =>
request<ApiResponse<UpdateCheckResult[]>>("/api/system/update-check"),
runUpdate: (repoId: string, version: string, url: string) =>
request<ApiResponse<unknown>>("/api/system/update", {
method: "POST",
body: JSON.stringify({ repoId, version, url }),
}),
reloadConfigs: (file?: string) =>
request<ApiResponse<unknown>>("/api/system/reload-configs", {
method: "POST",
body: JSON.stringify(file ? { file } : {}),
}),
getLogsMeta: () =>
request<
ApiResponse<{
types: LogType[];
files: { type: LogType; date: string; file: string | null; size: number }[];
}>
>("/api/system/logs/meta"),
getLogs: (params: {
types?: LogType[];
cursor?: Record<string, number>;
tail?: number;
}) => {
const q = new URLSearchParams();
if (params.types?.length) q.set("types", params.types.join(","));
if (params.cursor && Object.keys(params.cursor).length > 0) {
q.set("cursor", JSON.stringify(params.cursor));
}
if (params.tail != null) q.set("tail", String(params.tail));
const qs = q.toString();
return request<ApiResponse<LogsPayload>>(
`/api/system/logs${qs ? `?${qs}` : ""}`,
);
},
listTranscripts: () =>
request<
ApiResponse<
{
id: string;
filename: string;
createdAt: string;
size?: number;
ticketName?: string | null;
}[]
>
>("/api/system/transcripts"),
listOpenTickets: () =>
request<
ApiResponse<{
guildId: string;
departmentsEnabled?: boolean;
configuredDepartments?: { id: string; name: string; emoji?: string }[];
tickets: {
ticket_id: string;
user_id: string;
subject: string;
description: string;
department: string;
priority: string;
created_at: number;
}[];
}>
>("/api/system/tickets/open"),
createTicket: (data: { department?: string; subject?: string; reason?: string; userId?: string }) =>
request<ApiResponse<{ message: string }>>("/api/system/tickets/create", {
method: "POST",
body: JSON.stringify(data),
}),
getTicketMessages: (ticketId: string) =>
request<
ApiResponse<{
ticketId: string;
messages: {
id: string;
author: {
id: string;
username: string;
globalName: string;
avatar: string;
bot: boolean;
isDashboard?: boolean;
};
content: string;
embeds?: { title?: string; description?: string; color?: string | null }[];
attachments?: { id: string; name: string; url: string; contentType?: string }[];
createdAt: number;
}[];
}>
>(`/api/system/tickets/${encodeURIComponent(ticketId)}/messages`),
sendTicketReply: (
ticketId: string,
message: string,
staffName?: string,
attachments?: { filename: string; data: string }[],
) =>
request<
ApiResponse<{
id: string;
author: {
id: string;
username: string;
globalName: string;
avatar: string;
bot: boolean;
isDashboard?: boolean;
};
content: string;
attachments?: { id: string; name: string; url: string; contentType?: string }[];
createdAt: number;
}>
>(`/api/system/tickets/${encodeURIComponent(ticketId)}/reply`, {
method: "POST",
body: JSON.stringify({ message, staffName, attachments }),
}),
deleteTicketMessage: (ticketId: string, messageId: string) =>
request<ApiResponse<unknown>>(
`/api/system/tickets/${encodeURIComponent(ticketId)}/messages/${encodeURIComponent(messageId)}`,
{ method: "DELETE" },
),
editTicketMessage: (ticketId: string, messageId: string, content: string) =>
request<ApiResponse<{ id: string; content: string }>>(
`/api/system/tickets/${encodeURIComponent(ticketId)}/messages/${encodeURIComponent(messageId)}`,
{
method: "PATCH",
body: JSON.stringify({ content }),
},
),
closeTicket: (ticketId: string, reason?: string) =>
request<ApiResponse<unknown>>(
`/api/system/tickets/${encodeURIComponent(ticketId)}/close`,
{
method: "POST",
body: JSON.stringify({ reason }),
},
),
getTranscript: (id: string) =>
request<ApiResponse<string>>(`/api/system/transcripts/${id}`),
getTranscriptSettings: () =>
request<
ApiResponse<{
autoDelete: { enabled: boolean; afterDays: number };
publicAccess: { enabled: boolean };
publicBasePath?: string;
}>
>("/api/system/transcript-settings"),
updateTranscriptSettings: (body: {
autoDelete?: { enabled?: boolean; afterDays?: number };
publicAccess?: { enabled?: boolean };
}) =>
request<
ApiResponse<{
autoDelete: { enabled: boolean; afterDays: number };
publicAccess: { enabled: boolean };
publicBasePath?: string;
}>
>("/api/system/transcript-settings", {
method: "PUT",
body: JSON.stringify(body),
}),
deleteTranscript: (id: string) =>
request<ApiResponse<unknown>>(`/api/system/transcripts/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
purgeTranscripts: () =>
request<
ApiResponse<{ deleted: number; skipped: boolean }>
>("/api/system/transcripts/purge", { method: "POST" }),
getBranding: () =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding"),
updateBranding: (body: { title?: string; pageTitle?: string }) =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding", {
method: "PUT",
body: JSON.stringify(body),
}),
uploadBrandingFavicon: (dataUrl: string) =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding/favicon", {
method: "POST",
body: JSON.stringify({ dataUrl }),
}),
removeBrandingFavicon: () =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding/favicon", { method: "DELETE" }),
resetBranding: () =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding/reset", { method: "POST" }),
getTranscriptTemplate: () =>
request<ApiResponse<Record<string, unknown>>>(
"/api/system/transcript-template",
),
saveTranscriptTemplate: (data: Record<string, unknown>) =>
request<ApiResponse<Record<string, unknown>>>(
"/api/system/transcript-template",
{
method: "PUT",
body: JSON.stringify(data),
},
),
previewTranscriptTemplate: async (
data: Record<string, unknown>,
): Promise<string> => {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch("/api/system/transcript-template/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(data),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`Preview failed (${res.status})`);
}
return await res.text();
} finally {
window.clearTimeout(timeout);
}
},
getNotifications: () =>
request<ApiResponse<NotificationsPayload>>("/api/notifications"),
markNotificationsRead: (ids: string[] | "all") =>
request<ApiResponse<{ unreadCount: number }>>("/api/notifications/read", {
method: "POST",
body: JSON.stringify({ ids }),
}),
dismissNotification: (id: string) =>
request<ApiResponse<{ unreadCount: number }>>(`/api/notifications/${id}`, {
method: "DELETE",
}),
listDashboardUsers: () =>
request<
ApiResponse<{
users: StoredDashboardUser[];
roles: DashboardRole[];
configFiles: string[];
}>
>("/api/dashboard-users"),
addDashboardUser: (body: {
id: string;
role: DashboardRole;
username?: string;
globalName?: string;
permissions?: DashboardPermissions;
enabled?: boolean;
}) =>
request<ApiResponse<StoredDashboardUser>>("/api/dashboard-users", {
method: "POST",
body: JSON.stringify(body),
}),
updateDashboardUser: (
id: string,
body: {
role?: DashboardRole;
enabled?: boolean;
username?: string;
globalName?: string;
permissions?: DashboardPermissions;
},
) =>
request<ApiResponse<StoredDashboardUser>>(`/api/dashboard-users/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
removeDashboardUser: (id: string) =>
request<ApiResponse<unknown>>(`/api/dashboard-users/${id}`, {
method: "DELETE",
}),
listGroups: () =>
request<
ApiResponse<{
groups: DashboardGroup[];
}>
>("/api/dashboard-groups"),
createGroup: (body: {
name: string;
description?: string;
color?: string;
discordRoleId?: string | null;
permissions?: Partial<DashboardPermissions>;
}) =>
request<ApiResponse<DashboardGroup>>("/api/dashboard-groups", {
method: "POST",
body: JSON.stringify(body),
}),
updateGroup: (
id: string,
body: {
name?: string;
description?: string;
color?: string;
discordRoleId?: string | null;
permissions?: Partial<DashboardPermissions>;
},
) =>
request<ApiResponse<DashboardGroup>>(`/api/dashboard-groups/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteGroup: (id: string) =>
request<ApiResponse<unknown>>(`/api/dashboard-groups/${id}`, {
method: "DELETE",
}),
getAddonsCatalog: () =>
request<
ApiResponse<{
repository: string;
addons: CatalogAddon[];
}>
>("/api/addons/catalog"),
getAddonsInstalled: () =>
request<
ApiResponse<{
installed: { id: string; name: string; jsFiles: string[]; configFiles: string[] }[];
configs: string[];
}>
>("/api/addons/installed"),
listAddonConfigs: () =>
request<ApiResponse<string[]>>("/api/addons/configs"),
installAddon: (name: string) =>
request<ApiResponse<unknown>>("/api/addons/install", {
method: "POST",
body: JSON.stringify({ name }),
}),
getAddonConfigJson: (file: string) =>
request<ApiResponse<Record<string, unknown>>>(
`/api/addons/configs/json/${encodeURIComponent(file)}`,
),
saveAddonConfig: (filename: string, data: Record<string, unknown>) =>
request<ApiResponse<unknown>>("/api/addons/configs", {
method: "PUT",
body: JSON.stringify({ filename, data }),
}),
buildAddon: (payload: { name: string; description: string; permission: string; embed: any }) =>
request<ApiResponse<{ success: boolean }>>("/api/addons/build", {
method: "POST",
body: JSON.stringify(payload),
}),
getSetupStatus: () =>
request<ApiResponse<import("@/lib/setup-types").SetupStatus>>("/api/setup/status"),
validateSetup: (body: Record<string, unknown>) =>
request<ApiResponse<import("@/lib/setup-types").SetupValidateResponse>>(
"/api/setup/validate",
{ method: "POST", body: JSON.stringify(body) },
),
resetDashboard: () =>
request<ApiResponse<unknown>>("/api/setup/reset", {
method: "POST",
}),
completeSetup: (body: Record<string, unknown>) =>
request<
ApiResponse<{ setupComplete: boolean; botRestart?: BotRestartResult }>
>("/api/setup/complete", {
method: "POST",
body: JSON.stringify(body),
}),
};
export function displayDiscordName(
user: Pick<DashboardUser, "username" | "globalName">,
): string {
return user.globalName || user.username;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
+26
View File
@@ -0,0 +1,26 @@
import { Outlet } from "react-router-dom";
import { AppSidebar } from "@/components/app-sidebar";
import { DashboardHeader } from "@/components/dashboard-header";
import { DashboardAlerts } from "@/components/dashboard-alerts";
import { BotStatsProvider } from "@/context/BotStatsContext";
import { NotificationsProvider } from "@/context/NotificationsContext";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export default function Layout() {
return (
<BotStatsProvider>
<NotificationsProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset className="min-h-svh bg-background">
<DashboardHeader />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">
<DashboardAlerts />
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
</NotificationsProvider>
</BotStatsProvider>
);
}
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from "react";
import { AlertCircleIcon, FloppyDiskIcon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { ConfigFieldInput } from "@/components/config/config-field";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { schemaFromObject } from "@/lib/generic-config-schema";
import { getByPath, setByPath } from "@/lib/config-utils";
import { useAuth } from "@/context/AuthContext";
import { notifyConfigSave } from "@/lib/notify-config-save";
interface AddonConfigEditorProps {
filename: string;
}
export function AddonConfigEditor({ filename }: AddonConfigEditorProps) {
const { canEditConfig } = useAuth();
const readOnly = !canEditConfig("supportbot");
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [original, setOriginal] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getAddonConfigJson(filename);
const raw = (res.data ?? {}) as Record<string, unknown>;
setData(raw);
setOriginal(structuredClone(raw));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load config");
} finally {
setLoading(false);
}
}, [filename]);
useEffect(() => {
load();
}, [load]);
const schema = data ? schemaFromObject(data) : null;
function patch(path: string, value: unknown) {
setData((prev) => (prev ? setByPath(prev, path, value) : prev));
}
async function handleSave() {
if (readOnly || !data) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await notifyConfigSave(() =>
api.saveAddonConfig(filename, data),
);
setMessage(res.message || "Saved.");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
}
if (!data || !schema) {
return (
<Alert variant="destructive">
<AlertDescription>{error || "Failed to load"}</AlertDescription>
</Alert>
);
}
const dirty =
original && JSON.stringify(data) !== JSON.stringify(original);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-end gap-2">
{dirty ? (
<span className="text-xs text-amber-500">Unsaved changes</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading || saving}
>
Reload
</Button>
{!readOnly ? (
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
{saving ? "Saving…" : "Save"}
</Button>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
</div>
{error ? (
<Alert variant="destructive">
<Icon icon={AlertCircleIcon} size={18} />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert className="border-primary/30 bg-primary/10 text-primary">
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
{schema.map((section) => (
<Card key={section.title} className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description ? (
<CardDescription>{section.description}</CardDescription>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{section.fields.map((field) => (
<div
key={field.path}
className={
field.type === "textarea" || field.type === "stringList"
? "sm:col-span-2"
: ""
}
>
<ConfigFieldInput
field={field}
value={getByPath(data, field.path)}
readOnly={readOnly}
onChange={(v) => patch(field.path, v)}
/>
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useState } from "react";
import type { IconSvgElement } from "@hugeicons/react";
import {
AiBrain01Icon,
ArrowDown01Icon,
BotIcon,
CommandIcon,
ComputerTerminal01Icon,
DashboardSquare02Icon,
File02Icon,
Message01Icon,
PlusSignIcon,
PackageIcon,
Settings02Icon,
Shield01Icon,
Ticket01Icon,
UserMultiple02Icon,
} from "@hugeicons/core-free-icons";
import { NavLink, useSearchParams } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { useBranding } from "@/context/BrandingContext";
import { api, type GuildResources } from "@/api/client";
import { useBotStats } from "@/context/BotStatsContext";
import { useAddonConfigs } from "@/hooks/use-addon-configs";
import {
addonConfigFileParam,
addonConfigLabel,
} from "@/lib/addon-config";
import { canViewConfig, hasPermission } from "@/lib/permissions";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
const mainNav: {
title: string;
url: string;
icon: IconSvgElement;
end?: boolean;
permission?: string;
}[] = [];
const sections: {
label: string;
items: { title: string; url: string; icon: IconSvgElement; permission?: string; end?: boolean }[];
}[] = [
{
label: "Overview",
items: [
{ title: "Dashboard", url: "/", icon: DashboardSquare02Icon, end: true, permission: "overview" },
{ title: "Console", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" },
],
},
{
label: "Access & Logs",
items: [
{ title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
{ title: "Groups", url: "/groups", icon: Shield01Icon, permission: "users.view" },
{ title: "Tickets", url: "/tickets", icon: Ticket01Icon, permission: "overview" },
{ title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
],
},
{
label: "Configuration",
items: [
{ title: "Bot Settings", url: "/configs?file=supportbot", icon: BotIcon },
{ title: "Ticket Panel", url: "/configs?file=ticket-panel", icon: Ticket01Icon },
{ title: "AI Assistant", url: "/configs?file=supportbot-ai", icon: AiBrain01Icon },
{ title: "Messages", url: "/configs?file=messages", icon: Message01Icon },
{ title: "Staff Roles", url: "/configs?file=supportbot&tab=Roles", icon: UserMultiple02Icon },
{ title: "Commands", url: "/configs?file=commands", icon: CommandIcon },
],
},
{
label: "System",
items: [
{ title: "Addons", url: "/addons", icon: PackageIcon, permission: "settings.view" },
{ title: "Addon Builder", url: "/addons/builder", icon: CommandIcon, permission: "settings.update" },
{ title: "Dashboard Settings", url: "/settings", icon: Settings02Icon, permission: "settings.view" },
],
},
];
function configFileFromUrl(url: string) {
return new URL(url, "http://x").searchParams.get("file");
}
export function AppSidebar() {
const { branding } = useBranding();
const { permissions } = useAuth();
const { stats } = useBotStats();
const { files: addonConfigFiles } = useAddonConfigs();
const [searchParams] = useSearchParams();
const activeFile = searchParams.get("file");
const addonSection =
canViewConfig(permissions, "supportbot") && addonConfigFiles.length > 0
? {
label: "Addons",
items: addonConfigFiles.map((filename) => ({
title: addonConfigLabel(filename),
url: `/configs?file=${addonConfigFileParam(filename)}`,
icon: PackageIcon,
end: false,
})),
}
: null;
const visibleMainNav = mainNav.filter(
(item) => !item.permission || hasPermission(permissions, item.permission),
);
const visibleSections = sections
.map((section) => ({
...section,
items: section.items.filter((item) => {
if (item.permission) {
return hasPermission(permissions, item.permission);
}
const file = configFileFromUrl(item.url);
if (file && item.url.includes("/configs")) {
return canViewConfig(permissions, file);
}
return true;
}),
}))
.filter((section) => section.items.length > 0);
const allSections = addonSection
? [...visibleSections, addonSection]
: visibleSections;
const [guildResources, setGuildResources] = useState<GuildResources | null>(null);
useEffect(() => {
let cancelled = false;
api
.getGuildResources()
.then((res) => {
if (!cancelled && res.data) {
setGuildResources(res.data);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const serverName =
guildResources?.guildName ||
(stats ? `${stats.servers} Server${stats.servers === 1 ? "" : "s"}` : "Connected Guild");
const serverIcon = guildResources?.guildIcon;
return (
<Sidebar collapsible="icon" className="border-r border-sidebar-border">
<SidebarHeader className="gap-3 p-4">
<div className="flex items-center gap-2 px-1">
{branding.hasFavicon && branding.faviconUrl ? (
<img
src={`${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`}
alt=""
className="size-7 shrink-0 rounded-md object-contain"
/>
) : null}
<span className="truncate text-lg font-bold tracking-tight">
{branding.title}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-10 w-full justify-between border-border bg-secondary/40 px-3 font-normal hover:bg-secondary/60"
>
<span className="flex items-center gap-2 truncate min-w-0">
{serverIcon ? (
<img
src={serverIcon}
alt=""
className="size-5 rounded-full object-cover shrink-0"
/>
) : (
<div className="size-5 rounded-full bg-primary/20 text-primary flex items-center justify-center text-[10px] font-bold shrink-0">
{serverName.charAt(0).toUpperCase()}
</div>
)}
<span className="truncate text-xs font-semibold text-foreground">
{serverName}
</span>
</span>
<Icon icon={ArrowDown01Icon} size={14} className="text-muted-foreground shrink-0 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
<DropdownMenuItem disabled className="text-xs font-medium">
Connected Guild: {serverName}
</DropdownMenuItem>
{stats ? (
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
{stats.users.toLocaleString()} members tracked
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</SidebarHeader>
<SidebarContent className="px-2">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{visibleMainNav.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
end={item.end}
className={({ isActive }) =>
cn(isActive && "bg-sidebar-accent font-medium")
}
>
<Icon icon={item.icon} size={18} />
<span>{item.title}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{allSections.map((section) => (
<SidebarGroup key={section.label}>
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80">
{section.label}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{section.items.map((item) => {
const file = configFileFromUrl(item.url);
const isConfigActive =
Boolean(file) &&
activeFile === file &&
item.url.includes("/configs");
return (
<SidebarMenuItem key={`${section.label}-${item.title}`}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
end={item.end}
className={({ isActive }) => cn(
(isActive || isConfigActive) && "bg-sidebar-accent font-medium",
)}
>
<Icon icon={item.icon} size={18} />
<span>{item.title}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}
@@ -0,0 +1,106 @@
import type { IconSvgElement } from "@hugeicons/react";
import { CommandIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export interface CommandEntry {
key: string;
Command?: string;
Description?: string;
Enabled?: boolean;
Permission?: string[];
}
interface CommandsEditorProps {
data: Record<string, unknown>;
onChange: (data: Record<string, unknown>) => void;
}
function isCommandBlock(v: unknown): v is CommandEntry {
return (
v !== null &&
typeof v === "object" &&
"Command" in (v as Record<string, unknown>)
);
}
export function getCommandEntries(
data: Record<string, unknown>,
): CommandEntry[] {
return Object.entries(data)
.filter(([, v]) => isCommandBlock(v))
.map(([key, v]) => ({ key, ...(v as CommandEntry) }));
}
export function CommandsEditor({ data, onChange }: CommandsEditorProps) {
const entries = getCommandEntries(data);
function updateEntry(
key: string,
patch: Partial<CommandEntry>,
) {
const current = data[key] as Record<string, unknown>;
onChange({
...data,
[key]: { ...current, ...patch },
});
}
if (entries.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No commands found in this config.
</p>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2">
{entries.map((entry) => (
<Card key={entry.key} className="border-border bg-card">
<CardHeader className="flex flex-row items-center gap-2 space-y-0 pb-2">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Icon icon={CommandIcon as IconSvgElement} size={18} />
</div>
<CardTitle className="text-base">{entry.key}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor={`${entry.key}-enabled`}>Enabled</Label>
<Switch
id={`${entry.key}-enabled`}
checked={entry.Enabled !== false}
onCheckedChange={(checked) =>
updateEntry(entry.key, { Enabled: checked })
}
/>
</div>
<div className="space-y-1.5">
<Label>Slash command</Label>
<Input
value={entry.Command ?? ""}
onChange={(e) =>
updateEntry(entry.key, { Command: e.target.value })
}
className="border-border bg-secondary/30 font-mono"
/>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input
value={entry.Description ?? ""}
onChange={(e) =>
updateEntry(entry.key, { Description: e.target.value })
}
className="border-border bg-secondary/30"
/>
</div>
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,248 @@
import type { ConfigField } from "@/lib/config-schemas";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColorPicker } from "@/components/ui/color-picker";
import { normalizeHex } from "@/lib/normalize-hex";
import { StringListEditor } from "./string-list-editor";
import { GoogleFontPicker } from "./google-font-picker";
import {
DiscordResourcePicker,
type DiscordResourceKind,
} from "./discord-resource-picker";
import { EmojiPicker } from "./emoji-picker";
const SECRET_PLACEHOLDER = "BOT_TOKEN";
const AI_KEY_PLACEHOLDER = "MODEL_API_KEY";
interface ConfigFieldInputProps {
field: ConfigField;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
export function ConfigFieldInput({
field,
value,
onChange,
readOnly,
}: ConfigFieldInputProps) {
const id = field.path.replace(/\./g, "-");
if (field.type === "googleFont") {
return (
<GoogleFontPicker
field={field}
value={value}
onChange={onChange}
readOnly={readOnly}
/>
);
}
if (field.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-secondary/20 px-3 py-2.5">
<div>
<Label htmlFor={id} className="font-medium">
{field.label}
</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
</div>
<Switch
id={id}
checked={Boolean(value)}
disabled={readOnly}
onCheckedChange={onChange}
/>
</div>
);
}
if (field.type === "stringList") {
return (
<div className="space-y-2">
<Label>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<StringListEditor
value={Array.isArray(value) ? (value as string[]) : []}
onChange={onChange}
/>
</div>
);
}
if (field.type === "select" && field.options) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
<Select
value={String(value ?? "")}
disabled={readOnly}
onValueChange={onChange}
>
<SelectTrigger id={id} className="border-border bg-secondary/30">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (field.type === "color") {
const hex = normalizeHex(String(value ?? "#000000"));
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<div className="flex gap-2">
<ColorPicker value={hex} onChange={onChange} />
<Input
id={id}
value={hex}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => onChange(normalizeHex(e.target.value, hex))}
className="border-border bg-secondary/30 font-mono uppercase"
placeholder="#000000"
maxLength={7}
/>
</div>
</div>
);
}
if (field.type === "emoji") {
return (
<EmojiPicker
id={id}
label={field.label}
description={field.description}
value={value}
onChange={onChange}
readOnly={readOnly}
/>
);
}
if (
field.type === "discordChannel" ||
field.type === "discordRole" ||
field.type === "discordCategory"
) {
const kind: DiscordResourceKind =
field.type === "discordRole"
? "role"
: field.type === "discordCategory"
? "category"
: "channel";
return (
<DiscordResourcePicker
id={id}
label={field.label}
description={field.description}
kind={kind}
value={value}
onChange={onChange}
readOnly={readOnly}
/>
);
}
if (field.type === "secret") {
const display =
value === SECRET_PLACEHOLDER ||
value === AI_KEY_PLACEHOLDER ||
!value
? ""
: "••••••••••••";
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<Input
id={id}
type="password"
value={display}
disabled={readOnly}
placeholder={field.placeholder ?? "Unchanged if empty"}
onChange={(e) => {
const v = e.target.value;
if (v) onChange(v);
else onChange(field.placeholder ?? SECRET_PLACEHOLDER);
}}
className="border-border bg-secondary/30"
/>
</div>
);
}
const sharedClass = "border-border bg-secondary/30";
if (field.type === "textarea") {
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
<Textarea
id={id}
value={String(value ?? "")}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
className={`min-h-[88px] ${sharedClass}`}
/>
</div>
);
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<Input
id={id}
type={field.type === "number" ? "number" : "text"}
value={value === undefined || value === null ? "" : String(value)}
disabled={readOnly}
onChange={(e) =>
onChange(
field.type === "number"
? Number(e.target.value)
: e.target.value,
)
}
placeholder={
field.placeholder ??
(field.type === "discordId" ? "Discord snowflake ID" : undefined)
}
className={sharedClass}
/>
</div>
);
}
@@ -0,0 +1,661 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { AlertCircleIcon, FloppyDiskIcon, Add01Icon, Delete02Icon } from "@hugeicons/core-free-icons";
import { api, maskConfigSecrets } from "@/api/client";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { getSchema, type ConfigSchema } from "@/lib/config-schemas";
import { getByPath, setByPath } from "@/lib/config-utils";
import { mergeSchemaWithData } from "@/lib/merge-config-schema";
import { useAuth } from "@/context/AuthContext";
import { notifyConfigSave } from "@/lib/notify-config-save";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import Editor from "@monaco-editor/react";
import { ConfigFieldInput } from "./config-field";
import { CommandsEditor } from "./commands-editor";
import { hasPermission } from "@/lib/permissions";
type EditorMode = "visual" | "yaml";
interface ConfigVisualEditorProps {
configFile: string;
}
function maskSecretsInData(
file: string,
data: Record<string, unknown>,
): Record<string, unknown> {
let next = structuredClone(data);
if (file === "supportbot") {
const token = getByPath(next, "General.Token");
if (token && token !== "BOT_TOKEN") {
next = setByPath(next, "General.Token", "BOT_TOKEN");
}
}
if (file === "supportbot-ai") {
const key = getByPath(next, "General.Model_API_Key");
if (key && key !== "MODEL_API_KEY") {
next = setByPath(next, "General.Model_API_Key", "MODEL_API_KEY");
}
}
return next;
}
function buildUpdates(
file: string,
schema: ConfigSchema,
data: Record<string, unknown>,
original: Record<string, unknown>,
): Record<string, unknown> {
const updates: Record<string, unknown> = {};
for (const section of schema) {
for (const field of section.fields) {
const val = getByPath(data, field.path);
const orig = getByPath(original, field.path);
if (field.type === "secret") {
if (
val === "BOT_TOKEN" ||
val === "MODEL_API_KEY" ||
val === "" ||
val === undefined
) {
continue;
}
}
if (JSON.stringify(val) !== JSON.stringify(orig)) {
updates[`${file}:${field.path}`] = val;
}
}
}
return updates;
}
interface GroupedFields {
standalone: ConfigField[];
groups: { key: string; name: string; fields: ConfigField[] }[];
}
function getGroupedSectionFields(
fields: ConfigField[],
data: Record<string, unknown> | null
): GroupedFields {
const subGroupMap = new Map<string, ConfigField[]>();
const standalone: ConfigField[] = [];
for (const field of fields) {
const parts = field.path.split(".");
if (parts.length >= 5 && parts[1] === "DepartmentSystem" && parts[2] === "Departments") {
const groupKey = parts[3];
const list = subGroupMap.get(groupKey) ?? [];
list.push(field);
subGroupMap.set(groupKey, list);
} else if (parts.length >= 3) {
const groupKey = parts[1];
const list = subGroupMap.get(groupKey) ?? [];
list.push(field);
subGroupMap.set(groupKey, list);
} else {
standalone.push(field);
}
}
if (subGroupMap.size === 0) {
return { standalone: fields, groups: [] };
}
const groups = [...subGroupMap.entries()].map(([groupKey, groupFields]) => {
const fieldOrder = [
"enabled",
"name",
"description",
"emoji",
"category",
"role",
"channelprefix",
"defaultpriority",
"questions",
];
groupFields.sort((a, b) => {
const aKey = a.path.split(".").pop()?.toLowerCase() || "";
const bKey = b.path.split(".").pop()?.toLowerCase() || "";
const aIdx = fieldOrder.indexOf(aKey);
const bIdx = fieldOrder.indexOf(bKey);
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
if (aIdx !== -1) return -1;
if (bIdx !== -1) return 1;
return aKey.localeCompare(bKey);
});
const nameField = groupFields.find(
(f) => f.path.toLowerCase().endsWith(".name") || f.path.toLowerCase().endsWith(".title")
);
let displayName = "";
if (nameField && data) {
const val = getByPath(data, nameField.path);
if (typeof val === "string" && val.trim()) {
displayName = val.trim();
}
}
if (!displayName) {
displayName = groupKey
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
return {
key: groupKey,
name: displayName,
fields: groupFields,
};
});
return { standalone, groups };
}
export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
const { permissions: effectivePermissions, canEditConfig } = useAuth();
const canRawYaml = hasPermission(effectivePermissions, "configs.raw_yaml");
const readOnly = !canEditConfig(configFile);
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [original, setOriginal] = useState<Record<string, unknown> | null>(
null,
);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [mode, setMode] = useState<EditorMode>("visual");
useEffect(() => {
if (!canRawYaml && mode === "yaml") {
setMode("visual");
}
}, [canRawYaml, mode]);
const [rawYaml, setRawYaml] = useState("");
const [rawOriginal, setRawOriginal] = useState("");
const [searchParams] = useSearchParams();
const [activeSectionTitle, setActiveSectionTitle] = useState<string | null>(
() => searchParams.get("tab") || null
);
const [addDeptOpen, setAddDeptOpen] = useState(false);
const [newDeptKey, setNewDeptKey] = useState("");
const [newDeptName, setNewDeptName] = useState("");
const handleCreateDepartment = () => {
const key = newDeptKey.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
if (!key || !data) return;
const name = newDeptName.trim() || key;
const prefix = `Ticket.DepartmentSystem.Departments.${key}`;
const next = { ...data };
setByPath(next, `${prefix}.Name`, name);
setByPath(next, `${prefix}.Description`, `Open a ${name} ticket`);
setByPath(next, `${prefix}.Emoji`, "🎫");
setByPath(next, `${prefix}.Category`, "CATEGORY_ID");
setByPath(next, `${prefix}.Role`, "ROLE_ID");
setByPath(next, `${prefix}.ChannelPrefix`, `${key}-`);
setByPath(next, `${prefix}.DefaultPriority`, "normal");
setByPath(next, `${prefix}.Questions`, ["What do you need assistance with?"]);
setData(next);
setAddDeptOpen(false);
setNewDeptKey("");
setNewDeptName("");
};
const handleDeleteDepartment = (deptKey: string) => {
if (!data) return;
const prefix = `Ticket.DepartmentSystem.Departments.${deptKey}`;
const next = JSON.parse(JSON.stringify(data));
Object.keys(next).forEach((k) => {
if (k.startsWith(prefix) || k === prefix) {
delete next[k];
}
});
if (next.Ticket && typeof next.Ticket === "object") {
const deptSys = (next.Ticket as any).DepartmentSystem;
if (deptSys && typeof deptSys === "object" && deptSys.Departments && typeof deptSys.Departments === "object") {
delete deptSys.Departments[deptKey];
}
}
setData(next);
};
const baseSchema = getSchema(configFile);
const effectiveSchema = useMemo(() => {
if (!baseSchema || baseSchema === "commands" || !data) return baseSchema;
return mergeSchemaWithData(baseSchema, data);
}, [baseSchema, data]);
useEffect(() => {
if (effectiveSchema && effectiveSchema !== "commands") {
const tabParam = searchParams.get("tab");
if (tabParam && effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase())) {
const found = effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase());
if (found && found.title !== activeSectionTitle) {
setActiveSectionTitle(found.title);
}
} else if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) {
setActiveSectionTitle(effectiveSchema[0]?.title || null);
}
}
}, [effectiveSchema, activeSectionTitle, searchParams]);
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getConfigJson(configFile);
const raw = (res.data ?? {}) as Record<string, unknown>;
const masked = maskSecretsInData(configFile, raw);
setData(masked);
setOriginal(masked);
const rawRes = await api.getConfigRaw(configFile);
const content = rawRes.data ?? "";
const maskedRaw = maskConfigSecrets(content, configFile);
setRawYaml(maskedRaw);
setRawOriginal(maskedRaw);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load config");
} finally {
setLoading(false);
}
}, [configFile]);
useEffect(() => {
load();
}, [load]);
function patch(path: string, value: unknown) {
setData((prev) => (prev ? setByPath(prev, path, value) : prev));
}
async function handleSaveYaml() {
if (readOnly || rawYaml === rawOriginal) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await notifyConfigSave(() =>
api.saveConfigRaw(configFile, rawYaml),
);
setMessage(res.message || "Config saved.");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
async function handleSave() {
if (readOnly || !data || !original) return;
if (mode === "yaml") {
await handleSaveYaml();
return;
}
setSaving(true);
setError("");
setMessage("");
try {
if (baseSchema === "commands") {
const updates: Record<string, unknown> = {};
for (const key of Object.keys(data)) {
if (JSON.stringify(data[key]) !== JSON.stringify(original[key])) {
updates[`${configFile}:${key}`] = data[key];
}
}
if (Object.keys(updates).length === 0) {
setMessage("No changes to save.");
return;
}
const res = await notifyConfigSave(() =>
api.updateConfigFields(updates),
);
setMessage(res.message || "");
} else if (effectiveSchema && effectiveSchema !== "commands") {
const updates = buildUpdates(
configFile,
effectiveSchema,
data,
original,
);
if (Object.keys(updates).length === 0) {
setMessage("No changes to save.");
return;
}
const res = await notifyConfigSave(() =>
api.updateConfigFields(updates),
);
setMessage(res.message || "");
}
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (baseSchema === null) {
return (
<Alert>
<AlertDescription>
No visual editor available for this file.
</AlertDescription>
</Alert>
);
}
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
}
if (!data) {
return (
<Alert variant="destructive">
<AlertDescription>{error || "Failed to load"}</AlertDescription>
</Alert>
);
}
const dirtyVisual =
original && JSON.stringify(data) !== JSON.stringify(original);
const dirtyYaml = rawYaml !== rawOriginal;
const dirty = mode === "yaml" ? dirtyYaml : dirtyVisual;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
{canRawYaml ? (
<div className="flex rounded-lg border border-border p-0.5">
<Button
type="button"
variant={mode === "visual" ? "secondary" : "ghost"}
size="sm"
className="h-8"
onClick={() => setMode("visual")}
>
Visual
</Button>
<Button
type="button"
variant={mode === "yaml" ? "secondary" : "ghost"}
size="sm"
className="h-8"
onClick={() => setMode("yaml")}
>
YAML
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<span className="text-xs text-amber-500">Unsaved changes</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading || saving}
>
Reload
</Button>
{!readOnly ? (
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
{saving ? "Saving…" : "Save"}
</Button>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
</div>
</div>
{error ? (
<Alert variant="destructive">
<Icon icon={AlertCircleIcon} size={18} />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert className="border-primary/30 bg-primary/10 text-primary">
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
{mode === "yaml" ? (
<Card className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base">Raw YAML</CardTitle>
<CardDescription>
Edit the full file. Use this for nested lists (e.g. welcome buttons)
or any option not in Visual mode.
</CardDescription>
</CardHeader>
<CardContent className="h-[min(520px,60vh)] overflow-hidden rounded-b-xl border-t border-border p-0">
<Editor
height="100%"
language="yaml"
theme="vs-dark"
value={rawYaml}
onChange={(value) => setRawYaml(value || "")}
options={{
readOnly: readOnly,
minimap: { enabled: false },
fontSize: 13,
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 16 },
tabSize: 2,
}}
/>
</CardContent>
</Card>
) : baseSchema === "commands" ? (
<CommandsEditor
data={data}
onChange={(next) => setData(next)}
/>
) : effectiveSchema && effectiveSchema !== "commands" ? (
<div className="grid gap-6 lg:grid-cols-[200px_1fr]">
<nav className="flex flex-col gap-1">
{effectiveSchema.map((section) => (
<button
key={section.title}
onClick={() => setActiveSectionTitle(section.title)}
className={cn(
"text-left px-3 py-2 text-sm rounded-md transition-colors",
activeSectionTitle === section.title
? "bg-primary text-primary-foreground font-medium"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
)}
>
{section.title}
</button>
))}
</nav>
<div className="min-w-0">
{effectiveSchema
.filter((section) => section.title === activeSectionTitle)
.map((section) => (
<Card key={section.title} className="border-border bg-card">
<CardHeader className="pb-3 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description ? (
<CardDescription>{section.description}</CardDescription>
) : null}
</div>
{section.title === "Ticket Departments" && !readOnly && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setAddDeptOpen(true)}
className="gap-1.5 text-xs h-8 shrink-0"
>
<Icon icon={Add01Icon} size={14} /> Add Department
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
{(() => {
const { standalone, groups } = getGroupedSectionFields(section.fields, data);
return (
<>
{standalone.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2">
{standalone.map((field) => (
<div
key={field.path}
className={
field.type === "textarea" || field.type === "stringList"
? "sm:col-span-2"
: ""
}
>
<ConfigFieldInput
field={field}
value={getByPath(data, field.path)}
readOnly={readOnly}
onChange={(v) => patch(field.path, v)}
/>
</div>
))}
</div>
)}
{groups.map((group) => (
<div
key={group.key}
className="rounded-xl border border-border bg-card/60 p-4 space-y-4 shadow-sm transition-all hover:border-primary/40"
>
<div className="flex items-center justify-between border-b border-border/60 pb-3">
<div className="flex items-center gap-2">
<span className="font-semibold text-base text-foreground">
{group.name}
</span>
<span className="text-xs font-mono px-2.5 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20 font-medium">
{section.title === "Ticket Departments" ? `Ticket.${group.key}` : group.key}
</span>
</div>
{section.title === "Ticket Departments" && group.key !== "DepartmentSystem" && !readOnly && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleDeleteDepartment(group.key)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
title="Delete Department"
>
<Icon icon={Delete02Icon} size={14} />
</Button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
{group.fields.map((field) => (
<div
key={field.path}
className={
field.type === "textarea" || field.type === "stringList"
? "sm:col-span-2"
: ""
}
>
<ConfigFieldInput
field={field}
value={getByPath(data, field.path)}
readOnly={readOnly}
onChange={(v) => patch(field.path, v)}
/>
</div>
))}
</div>
</div>
))}
</>
);
})()}
</CardContent>
</Card>
))}
</div>
</div>
) : null}
{/* Add Department Modal */}
<Dialog open={addDeptOpen} onOpenChange={setAddDeptOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base">Add Ticket Department</DialogTitle>
<DialogDescription className="text-xs">
Create a new support department for ticket creation.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<Label className="text-xs font-semibold">Department Key (Internal ID)</Label>
<Input
placeholder="billing"
value={newDeptKey}
onChange={(e) => setNewDeptKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
className="text-xs font-mono border-border bg-secondary/30"
/>
<p className="text-[11px] text-muted-foreground">e.g. <code>billing</code>, <code>technical</code>, <code>sales</code></p>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold">Department Display Name</Label>
<Input
placeholder="Billing & Sales Support"
value={newDeptName}
onChange={(e) => setNewDeptName(e.target.value)}
className="text-xs border-border bg-secondary/30"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" size="sm" onClick={() => setAddDeptOpen(false)} className="text-xs">
Cancel
</Button>
<Button size="sm" onClick={handleCreateDepartment} disabled={!newDeptKey.trim()} className="text-xs bg-primary font-semibold">
Create Department
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from "react";
import { Search01Icon, UnfoldMoreIcon } from "@hugeicons/core-free-icons";
import { api, type GuildResources } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Icon } from "@/components/icon";
import { cn } from "@/lib/utils";
export type DiscordResourceKind = "channel" | "role" | "category";
interface DiscordResourcePickerProps {
id: string;
label: string;
description?: string;
kind: DiscordResourceKind;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
export function DiscordResourcePicker({
id,
label,
description,
kind,
value,
onChange,
readOnly,
}: DiscordResourcePickerProps) {
const [resources, setResources] = useState<GuildResources | null>(null);
const [loadError, setLoadError] = useState("");
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.getGuildResources()
.then((res) => {
if (cancelled) return;
if (res.data) {
setResources(res.data);
setLoadError("");
}
})
.catch((err) => {
if (!cancelled) {
setLoadError(
err instanceof Error ? err.message : "Could not load server list",
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const options = useMemo(() => {
if (!resources) return [];
if (kind === "role") return resources.roles;
if (kind === "category") return resources.categories;
return resources.channels;
}, [resources, kind]);
const current = String(value ?? "");
const matched = options.find((o) => o.id === current);
const filteredOptions = useMemo(() => {
if (!search.trim()) return options;
const q = search.toLowerCase().trim();
return options.filter(
(opt) => opt.name.toLowerCase().includes(q) || opt.id.includes(q),
);
}, [options, search]);
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
{resources ? (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={readOnly || loading}
className="w-full justify-between border-border bg-secondary/30 text-left font-normal h-10 px-3 hover:bg-secondary/50"
>
<span className="truncate">
{matched ? (
<span className="flex items-center gap-2 min-w-0">
<span className="font-medium text-foreground truncate">{matched.name}</span>
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{matched.id}</span>
</span>
) : (
<span className="text-muted-foreground">
{loading
? "Loading server list…"
: `Select ${kind} from ${resources.guildName}…`}
</span>
)}
</span>
<Icon icon={UnfoldMoreIcon} size={16} className="shrink-0 opacity-50 ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-2 bg-popover border-border shadow-2xl rounded-xl flex flex-col gap-2 z-50">
<div className="relative flex items-center">
<Icon icon={Search01Icon} size={14} className="absolute left-2.5 text-muted-foreground" />
<Input
placeholder={`Search ${kind}s by name or ID…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-8 text-xs border-border bg-secondary/20 focus-visible:ring-1"
autoFocus
/>
</div>
<div className="overflow-y-auto max-h-56 space-y-1 pr-1">
{filteredOptions.length === 0 ? (
<div className="p-3 text-center text-xs text-muted-foreground">
No {kind}s found matching "{search}"
</div>
) : (
filteredOptions.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => {
onChange(opt.id);
setOpen(false);
setSearch("");
}}
className={cn(
"w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs transition-colors text-left",
opt.id === current
? "bg-primary/15 text-primary font-semibold border border-primary/20"
: "hover:bg-secondary/60 text-foreground"
)}
>
<span className="truncate mr-2 font-medium">{opt.name}</span>
<span className="font-mono text-[11px] text-muted-foreground shrink-0">
{opt.id}
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
) : null}
<Input
value={current}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
placeholder={
kind === "role"
? "Role ID (ROLE_ID)"
: kind === "category"
? "Category ID"
: "Channel ID"
}
className="border-border bg-secondary/30 font-mono text-xs"
/>
{loadError ? (
<p className="text-xs text-amber-500">{loadError}</p>
) : null}
{!loadError && !resources && !loading ? (
<p className="text-xs text-muted-foreground">
Set General.GuildId and invite the bot to pick from a list.
</p>
) : null}
</div>
);
}
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Search01Icon } from "@hugeicons/core-free-icons";
import { api, type GuildResources } from "@/api/client";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UnicodeEmojiPanel } from "@/components/config/unicode-emoji-panel";
import {
emojiCdnUrl,
formatCustomEmoji,
parseEmojiValue,
resolveEmojiPreview,
type GuildEmojiOption,
} from "@/lib/emoji-format";
import { cn } from "@/lib/utils";
interface EmojiPickerProps {
id: string;
label: string;
description?: string;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
function EmojiPreview({
value,
guildEmojis,
size = "md",
}: {
value: unknown;
guildEmojis: GuildEmojiOption[];
size?: "md" | "lg";
}) {
const parsed = resolveEmojiPreview(value, guildEmojis);
const imgSize = size === "lg" ? "size-10" : "size-7";
const textSize = size === "lg" ? "text-3xl" : "text-2xl";
if (parsed.kind === "empty") {
return (
<span className="text-sm text-muted-foreground">No emoji selected</span>
);
}
if (parsed.kind === "custom" && parsed.id) {
return (
<span className="inline-flex items-center gap-3">
<img
src={emojiCdnUrl(parsed.id, parsed.animated)}
alt={parsed.name ?? "custom emoji"}
className={cn(imgSize, "shrink-0 rounded-md")}
loading="lazy"
/>
<span className="flex flex-col items-start gap-0.5">
<span className="text-sm font-medium">:{parsed.name}:</span>
<span className="font-mono text-xs text-muted-foreground">
{parsed.value}
</span>
</span>
</span>
);
}
return (
<span className={cn(textSize, "leading-none")} title={parsed.value}>
{parsed.value}
</span>
);
}
export function EmojiPicker({
id,
label,
description,
value,
onChange,
readOnly,
}: EmojiPickerProps) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"server" | "unicode">("server");
const [resources, setResources] = useState<GuildResources | null>(null);
const [loadError, setLoadError] = useState("");
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const loadGuildEmojis = useCallback(async (refresh = false) => {
setLoading(true);
setLoadError("");
try {
const res = await api.getGuildResources(refresh);
if (res.data) {
setResources(res.data);
if (!Array.isArray(res.data.emojis)) {
setLoadError(
"Server response is missing emojis. Restart the bot after updating.",
);
}
} else {
setLoadError("No guild data returned.");
}
} catch (err) {
setLoadError(
err instanceof Error ? err.message : "Could not load server emojis",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void loadGuildEmojis(true);
}, [open, loadGuildEmojis]);
const guildEmojis = useMemo(
() => (Array.isArray(resources?.emojis) ? resources.emojis : []),
[resources],
);
const filteredGuild = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return guildEmojis;
return guildEmojis.filter((e) => e.name.toLowerCase().includes(q));
}, [guildEmojis, query]);
const current = String(value ?? "");
const parsed = parseEmojiValue(current);
function selectUnicode(emoji: string) {
onChange(emoji);
setOpen(false);
}
function selectGuild(emoji: GuildEmojiOption) {
onChange(formatCustomEmoji(emoji.name, emoji.id, emoji.animated));
setOpen(false);
}
function clearEmoji() {
onChange("");
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<EmojiPreview value={value} guildEmojis={guildEmojis} size="lg" />
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={readOnly}
onClick={() => setOpen(true)}
>
Choose emoji
</Button>
{current ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={readOnly}
onClick={clearEmoji}
>
Clear
</Button>
) : null}
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[min(90vh,720px)] max-w-lg gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="space-y-1 border-b border-border px-5 py-4">
<DialogTitle>Choose emoji</DialogTitle>
<DialogDescription>
Server emojis save as{" "}
<code className="rounded bg-muted px-1 text-xs">&lt;:name:id&gt;</code>
. Standard emojis save as Unicode characters.
</DialogDescription>
</DialogHeader>
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "server" | "unicode")}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="mx-5 mt-3 grid h-9 w-auto grid-cols-2">
<TabsTrigger value="server">Server</TabsTrigger>
<TabsTrigger value="unicode">All emojis</TabsTrigger>
</TabsList>
<TabsContent value="server" className="mt-0 flex-1 px-5 pb-4 pt-3">
<div className="relative mb-3">
<Icon
icon={Search01Icon}
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search by name…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 border-border bg-secondary/30 pl-9"
/>
</div>
{resources ? (
<p className="mb-2 text-xs text-muted-foreground">
{resources.guildName} · {guildEmojis.length} custom emoji
{guildEmojis.length === 1 ? "" : "s"}
</p>
) : null}
<ScrollArea className="h-[min(50vh,320px)] rounded-lg border border-border/50 bg-secondary/20">
{loading ? (
<div className="grid grid-cols-6 gap-2 p-3 sm:grid-cols-8">
{Array.from({ length: 24 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
) : loadError ? (
<div className="space-y-3 p-4 text-center">
<p className="text-sm text-destructive">{loadError}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadGuildEmojis(true)}
>
Retry
</Button>
</div>
) : filteredGuild.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">
{guildEmojis.length === 0
? "No custom emojis in this server, or the bot needs the Guild Emojis intent (restart after update)."
: "No emojis match your search."}
</p>
) : (
<div className="grid grid-cols-6 gap-1.5 p-2 sm:grid-cols-8">
{filteredGuild.map((emoji) => {
const selected =
parsed.kind === "custom" && parsed.id === emoji.id;
return (
<button
key={emoji.id}
type="button"
title={`:${emoji.name}:`}
className={cn(
"flex aspect-square items-center justify-center rounded-lg border border-transparent bg-background/50 transition-colors hover:border-primary/30 hover:bg-accent",
selected &&
"border-primary/50 bg-primary/15 ring-2 ring-primary/30",
)}
onClick={() => selectGuild(emoji)}
>
<img
src={emoji.url ?? emojiCdnUrl(emoji.id, emoji.animated)}
alt={emoji.name}
className="size-8 object-contain"
loading="lazy"
/>
</button>
);
})}
</div>
)}
</ScrollArea>
</TabsContent>
<TabsContent value="unicode" className="mt-0 px-5 pb-4 pt-3">
<UnicodeEmojiPanel onPick={selectUnicode} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
<Input
value={current}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
placeholder="Unicode or <:name:id>"
className="border-border bg-secondary/30 font-mono text-xs"
/>
</div>
);
}
@@ -0,0 +1,74 @@
import { useEffect } from "react";
import type { ConfigField } from "@/lib/config-schemas";
import {
GOOGLE_FONTS,
SYSTEM_FONT_ID,
fontFamilyCss,
injectGoogleFontsStylesheet,
normalizeGoogleFontValue,
} from "@/lib/google-fonts";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface GoogleFontPickerProps {
field: ConfigField;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
export function GoogleFontPicker({
field,
value,
onChange,
readOnly,
}: GoogleFontPickerProps) {
const id = field.path.replace(/\./g, "-");
const selected = normalizeGoogleFontValue(value);
useEffect(() => {
injectGoogleFontsStylesheet();
}, []);
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<Select
value={selected}
disabled={readOnly}
onValueChange={onChange}
>
<SelectTrigger
id={id}
className="border-border bg-secondary/30"
style={{ fontFamily: fontFamilyCss(selected) }}
>
<SelectValue placeholder="Choose a font…" />
</SelectTrigger>
<SelectContent className="max-h-[min(320px,50vh)]">
<SelectItem value={SYSTEM_FONT_ID} style={{ fontFamily: fontFamilyCss(SYSTEM_FONT_ID) }}>
System default
</SelectItem>
{GOOGLE_FONTS.map((font) => (
<SelectItem
key={font.id}
value={font.id}
style={{ fontFamily: fontFamilyCss(font.id) }}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,56 @@
import { Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface StringListEditorProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
}
export function StringListEditor({
value,
onChange,
placeholder = "Add item…",
}: StringListEditorProps) {
const items = Array.isArray(value) ? value : [];
return (
<div className="space-y-2">
{items.map((item, index) => (
<div key={index} className="flex gap-2">
<Input
value={item}
onChange={(e) => {
const next = [...items];
next[index] = e.target.value;
onChange(next);
}}
placeholder={placeholder}
className="border-border bg-secondary/30"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => onChange(items.filter((_, i) => i !== index))}
>
<Icon icon={Delete02Icon} size={16} />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed border-border"
onClick={() => onChange([...items, ""])}
>
<Icon icon={PlusSignIcon} size={16} className="mr-2" />
Add line
</Button>
</div>
);
}
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
import "emoji-picker-element";
interface UnicodeEmojiPanelProps {
onPick: (unicode: string) => void;
}
export function UnicodeEmojiPanel({ onPick }: UnicodeEmojiPanelProps) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const picker = document.createElement("emoji-picker");
picker.className = "unicode-emoji-picker-el";
picker.setAttribute("data-theme", "dark");
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ unicode?: string; emoji?: { unicode?: string } }>)
.detail;
const unicode = detail?.unicode ?? detail?.emoji?.unicode;
if (unicode) onPick(unicode);
};
picker.addEventListener("emoji-click", handler);
host.appendChild(picker);
return () => {
picker.removeEventListener("emoji-click", handler);
picker.remove();
};
}, [onPick]);
return (
<div
ref={hostRef}
className="unicode-emoji-panel-host overflow-hidden rounded-lg border border-border/50"
/>
);
}
@@ -0,0 +1,112 @@
import { Link } from "react-router-dom";
import {
AlertCircleIcon,
InformationCircleIcon,
Logout03Icon,
} from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
SEVERITY_STYLES,
type DashboardAlertPayload,
type DashboardAlertRenderContext,
type DashboardAlertSeverity,
} from "@/lib/dashboard-alerts";
function severityIcon(severity: DashboardAlertSeverity) {
if (severity === "info") return InformationCircleIcon;
return AlertCircleIcon;
}
interface DashboardAlertCardProps {
alert: DashboardAlertPayload;
context: DashboardAlertRenderContext;
}
export function DashboardAlertCard({ alert, context }: DashboardAlertCardProps) {
const styles = SEVERITY_STYLES[alert.severity];
const extraGuilds = alert.meta?.extraGuilds ?? [];
const guildStatus = alert.meta?.status;
const moduleProgress = alert.meta?.progress;
return (
<Alert
variant="destructive"
className={`${styles.border} ${styles.bg} text-foreground`}
>
<Icon icon={severityIcon(alert.severity)} size={18} className={styles.icon} />
<AlertTitle className={styles.title}>{alert.title}</AlertTitle>
<AlertDescription className="space-y-3 text-sm text-muted-foreground">
<p>{alert.message}</p>
{alert.category === "guild" && guildStatus === "missing_config" ? (
<p>
Open{" "}
<Link
to="/configs?file=supportbot"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Bot config
</Link>{" "}
and set <strong className="text-foreground">Server (guild) ID</strong>{" "}
(enable Developer Mode in Discord, then right-click your server → Copy
Server ID).
</p>
) : null}
{alert.category === "modules" && moduleProgress ? (
<p className="text-xs">
Progress: <span className="text-foreground">{moduleProgress}</span>
</p>
) : null}
{extraGuilds.length > 0 ? (
<ul className="space-y-2">
{extraGuilds.map((g) => (
<li
key={g.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-background/40 px-3 py-2"
>
<span>
<span className="font-medium text-foreground">{g.name}</span>
<span className="ml-2 font-mono text-xs">{g.id}</span>
</span>
{context.canLeaveGuild ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={context.leavingGuildId === g.id}
onClick={() => void context.onLeaveGuild(g.id)}
>
<Icon icon={Logout03Icon} size={14} className="mr-1.5" />
{context.leavingGuildId === g.id ? "Leaving…" : "Leave server"}
</Button>
) : null}
</li>
))}
</ul>
) : null}
{context.actionError ? (
<p className="text-destructive">{context.actionError}</p>
) : null}
<div className="flex flex-wrap items-center gap-2 pt-1">
{alert.action ? (
<Button asChild size="sm" variant="secondary">
<Link to={alert.action.href}>{alert.action.label}</Link>
</Button>
) : null}
{alert.category === "guild" ? (
<p className="text-xs">
The dashboard stays online while you fix this. Some features may be
limited until setup is complete.
</p>
) : null}
</div>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/api/client";
import { useAuth } from "@/context/AuthContext";
import {
dashboardAlertBuilder,
type DashboardAlertPayload,
} from "@/lib/dashboard-alerts";
import { DashboardAlertCard } from "./dashboard-alert-card";
const REFRESH_MS = 15_000;
export function DashboardAlerts() {
const { hasPermission } = useAuth();
const canLeave = hasPermission("settings.update");
const [alerts, setAlerts] = useState<DashboardAlertPayload[]>([]);
const [leavingGuildId, setLeavingGuildId] = useState<string | null>(null);
const [actionError, setActionError] = useState("");
const refresh = useCallback(async () => {
try {
const next = await dashboardAlertBuilder.loadAll();
setAlerts(next);
setActionError("");
} catch {
/* ignore — banners are best-effort */
}
}, []);
useEffect(() => {
void refresh();
const id = window.setInterval(() => void refresh(), REFRESH_MS);
return () => window.clearInterval(id);
}, [refresh]);
async function leaveGuild(guildId: string) {
if (!canLeave) return;
setLeavingGuildId(guildId);
setActionError("");
try {
await api.leaveGuild(guildId);
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Could not leave server");
} finally {
setLeavingGuildId(null);
}
}
if (alerts.length === 0) return null;
const renderContext = {
canLeaveGuild: canLeave,
onLeaveGuild: leaveGuild,
leavingGuildId,
actionError,
};
return (
<div className="space-y-3">
{alerts.map((alert) => (
<DashboardAlertCard key={alert.id} alert={alert} context={renderContext} />
))}
</div>
);
}
@@ -0,0 +1,2 @@
export { DashboardAlerts } from "./dashboard-alerts";
export { DashboardAlertCard } from "./dashboard-alert-card";
@@ -0,0 +1,198 @@
import {
ArrowDown01Icon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { DashboardSearch } from "@/components/dashboard-search";
import { useBotStats } from "@/context/BotStatsContext";
import { Icon } from "@/components/icon";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { NotificationBell } from "@/components/notification-bell";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarTrigger } from "@/components/ui/sidebar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { displayDiscordName, useAuth } from "@/context/AuthContext";
export function DashboardHeader() {
const { stats } = useBotStats();
const {
user,
logout,
availableGroups,
isImpersonating,
impersonatedGroup,
impersonateGroup,
stopImpersonation,
hasPermission,
} = useAuth();
const canImpersonate = user?.isOwner || hasPermission("users.manage");
const botName = stats?.bot.username ?? "SupportBot";
const accountName = user ? displayDiscordName(user) : botName;
const accountAvatar = user?.avatar ?? stats?.bot.avatar;
const inviteUrl = stats?.bot.inviteUrl;
const dateLabel = new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date());
return (
<>
{/* Impersonation Preview Mode Banner */}
{isImpersonating && impersonatedGroup && (
<div className="relative z-50 flex items-center justify-between gap-3 bg-gradient-to-r from-amber-500/10 via-amber-500/15 to-amber-500/10 border-b border-amber-500/30 px-4 py-2 text-xs backdrop-blur-md shadow-sm">
<div className="flex items-center gap-2.5">
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 text-[11px] font-bold tracking-wide uppercase border border-amber-500/30">
<span className="size-1.5 rounded-full bg-amber-400 animate-pulse" />
Preview Mode
</span>
<div className="flex items-center gap-2 text-foreground font-medium">
<span className="text-muted-foreground">Viewing dashboard as:</span>
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-secondary/80 border border-border/80">
<span
className="size-2 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: impersonatedGroup.color || "#10B981" }}
/>
<span className="font-semibold text-xs text-foreground">
{impersonatedGroup.name}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2.5">
{availableGroups.length > 1 && (
<Select
value={impersonatedGroup.id}
onValueChange={(val) => impersonateGroup(val)}
>
<SelectTrigger className="h-7 text-xs bg-background/80 border-amber-500/30 text-amber-200 focus:ring-amber-500/40 w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-border bg-card">
{availableGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full shrink-0"
style={{ backgroundColor: g.color || "#10B981" }}
/>
<span>{g.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs bg-card border-amber-500/40 text-amber-300 hover:bg-amber-500/20 hover:text-white transition-all shadow-sm"
onClick={stopImpersonation}
>
Exit Preview Mode
</Button>
</div>
</div>
)}
<header className="relative z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-4 md:px-6">
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-foreground md:hidden" />
<p className="hidden shrink-0 text-sm font-medium text-muted-foreground lg:block">
{dateLabel}
</p>
<DashboardSearch className="min-w-0 flex-1" />
<div className="ml-auto flex items-center gap-2">
{inviteUrl ? (
<Button
variant="ghost"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
asChild
>
<a
href={inviteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Invite bot to a server"
title="Invite bot to a server"
>
<Icon icon={PlusSignIcon} size={18} />
</a>
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
disabled
aria-label="Invite bot to a server"
title="Invite link available when the bot is online"
>
<Icon icon={PlusSignIcon} size={18} />
</Button>
)}
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-9 gap-2 rounded-full px-2 hover:bg-secondary"
>
<Avatar className="size-7">
<AvatarImage src={accountAvatar} alt={accountName} />
<AvatarFallback className="bg-primary/20 text-xs text-primary">
{accountName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="hidden max-w-[100px] truncate text-sm font-medium sm:inline">
{accountName}
</span>
<Icon icon={ArrowDown01Icon} size={16} className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuSeparator />
{user ? (
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
{user.username}
{user.id ? ` · ${user.id}` : ""}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
void logout();
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
</>
);
}
@@ -0,0 +1,189 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom";
import { Search01Icon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import {
groupSearchResults,
searchDashboard,
type SearchItem,
} from "@/lib/search-index";
export function DashboardSearch({ className }: { className?: string }) {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [panelRect, setPanelRect] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const results = useMemo(() => searchDashboard(query, 14), [query]);
const groups = useMemo(() => groupSearchResults(results), [results]);
const showPanel = open && query.trim().length > 0;
const updatePanelRect = () => {
const el = inputRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPanelRect({
top: rect.bottom + 6,
left: rect.left,
width: rect.width,
});
};
useLayoutEffect(() => {
if (!showPanel) {
setPanelRect(null);
return;
}
updatePanelRect();
window.addEventListener("resize", updatePanelRect);
window.addEventListener("scroll", updatePanelRect, true);
return () => {
window.removeEventListener("resize", updatePanelRect);
window.removeEventListener("scroll", updatePanelRect, true);
};
}, [showPanel, query]);
useEffect(() => {
function onPointerDown(e: MouseEvent) {
const target = e.target as Node;
if (
containerRef.current?.contains(target) ||
panelRef.current?.contains(target)
) {
return;
}
setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
function selectItem(item: SearchItem) {
navigate(item.href);
setQuery("");
setOpen(false);
inputRef.current?.blur();
}
return (
<div ref={containerRef} className={cn("relative w-full max-w-sm", className)}>
<Icon
icon={Search01Icon}
size={16}
className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
}}
placeholder="Search modules, configs…"
className="h-9 border-border bg-secondary/50 pl-9"
role="combobox"
aria-expanded={showPanel}
aria-controls="dashboard-search-results"
aria-autocomplete="list"
/>
<kbd className="pointer-events-none absolute right-2 top-2 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100 sm:flex">
<span className="text-xs">⌘</span>K
</kbd>
{showPanel && panelRect
? createPortal(
<div
ref={panelRef}
id="dashboard-search-results"
className="overflow-hidden rounded-xl border border-border bg-popover shadow-lg shadow-black/20"
style={{
position: "fixed",
top: panelRect.top,
left: panelRect.left,
width: panelRect.width,
zIndex: 9999,
}}
role="listbox"
>
<Command shouldFilter={false} className="bg-transparent">
<CommandList className="max-h-[min(360px,50vh)]">
{results.length === 0 ? (
<CommandEmpty className="py-8 text-muted-foreground">
No results for &ldquo;{query.trim()}&rdquo;
</CommandEmpty>
) : (
groups.map((group) => (
<CommandGroup key={group.category} heading={group.label}>
{group.items.map((item) => (
<CommandItem
key={item.id}
value={item.id}
onSelect={() => selectItem(item)}
className="cursor-pointer gap-3 rounded-lg py-2.5 aria-selected:bg-accent"
>
{item.icon ? (
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Icon icon={item.icon} size={16} />
</span>
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{item.title}
</span>
{item.subtitle ? (
<span className="block truncate text-xs text-muted-foreground">
{item.subtitle}
</span>
) : null}
</span>
</CommandItem>
))}
</CommandGroup>
))
)}
</CommandList>
</Command>
</div>,
document.body,
)
: null}
</div>
);
}
@@ -0,0 +1,14 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { firstAllowedPath, hasPermission } from "@/lib/permissions";
import Overview from "@/pages/Overview";
export function HomeRedirect() {
const { permissions } = useAuth();
if (hasPermission(permissions, "overview")) {
return <Overview />;
}
return <Navigate to={firstAllowedPath(permissions)} replace />;
}
+26
View File
@@ -0,0 +1,26 @@
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { cn } from "@/lib/utils";
interface IconProps {
icon: IconSvgElement;
className?: string;
size?: number;
strokeWidth?: number;
}
export function Icon({
icon,
className,
size = 18,
strokeWidth = 1.75,
}: IconProps) {
return (
<HugeiconsIcon
icon={icon}
size={size}
className={cn("shrink-0", className)}
strokeWidth={strokeWidth}
/>
);
}
@@ -0,0 +1,168 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
Cancel01Icon,
Notification01Icon,
} from "@hugeicons/core-free-icons";
import { useNotifications } from "@/context/NotificationsContext";
import { Icon } from "@/components/icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { DashboardNotification } from "@/api/client";
import { cn } from "@/lib/utils";
function formatRelativeTime(ts: number) {
const sec = Math.floor((Date.now() - ts) / 1000);
if (sec < 60) return "Just now";
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
function typeDotClass(type: DashboardNotification["type"]) {
switch (type) {
case "error":
return "bg-destructive";
case "update":
return "bg-amber-500";
case "bot":
return "bg-emerald-500";
default:
return "bg-primary";
}
}
export function NotificationBell() {
const navigate = useNavigate();
const { items, unreadCount, loading, markAllRead, dismiss, refresh } =
useNotifications();
const [open, setOpen] = useState(false);
const onOpenChange = (next: boolean) => {
setOpen(next);
if (next) {
void refresh();
if (unreadCount > 0) void markAllRead();
}
};
const onSelect = (n: DashboardNotification) => {
setOpen(false);
if (n.href) navigate(n.href);
};
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative size-9 text-muted-foreground"
aria-label="Notifications"
>
<Icon icon={Notification01Icon} size={18} />
{unreadCount > 0 && (
<Badge className="absolute -right-0.5 -top-0.5 flex size-4 min-w-4 items-center justify-center rounded-full bg-destructive p-0 text-[10px] text-destructive-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-3 py-2">
<DropdownMenuLabel className="p-0 text-sm font-semibold">
Notifications
</DropdownMenuLabel>
{items.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => void markAllRead()}
>
Mark all read
</Button>
)}
</div>
<DropdownMenuSeparator className="m-0" />
<ScrollArea className="max-h-[min(360px,50vh)]">
{loading && items.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Loading…
</p>
) : items.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications yet. Bot restarts and update checks will appear
here.
</p>
) : (
<ul className="py-1">
{items.map((n) => (
<li
key={n.id}
className={cn(
"group flex gap-2 border-b border-border/40 px-3 py-2.5 last:border-0",
!n.read && "bg-primary/5",
)}
>
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
typeDotClass(n.type),
)}
/>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => onSelect(n)}
>
<p className="text-sm font-medium leading-tight">
{n.title}
</p>
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{n.message}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/80">
{formatRelativeTime(n.createdAt)}
</p>
</button>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
void dismiss(n.id);
}}
>
<Icon icon={Cancel01Icon} size={14} />
</Button>
</li>
))}
</ul>
)}
</ScrollArea>
<DropdownMenuSeparator className="m-0" />
<div className="p-2">
<Button variant="ghost" size="sm" className="w-full text-xs" asChild>
<Link to="/settings" onClick={() => setOpen(false)}>
Open settings (updates)
</Link>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,52 @@
import { Link } from "react-router-dom";
import type { IconSvgElement } from "@hugeicons/react";
import { Icon } from "@/components/icon";
import { cn } from "@/lib/utils";
import type { ModuleStatus } from "@/components/overview/module-strip-card";
const BADGE_CLASS: Record<ModuleStatus, string> = {
todo: "bg-rose-500/15 text-rose-400",
progress: "bg-amber-500/15 text-amber-400",
done: "bg-emerald-500/15 text-emerald-400",
};
interface ActiveModuleCardProps {
title: string;
icon: IconSvgElement;
href: string;
badge?: string;
status?: ModuleStatus;
}
export function ActiveModuleCard({
title,
icon,
href,
badge = "Configured",
status = "done",
}: ActiveModuleCardProps) {
return (
<Link
to={href}
className={cn(
"flex items-center gap-3 rounded-2xl border border-border bg-card p-4 transition-colors",
"hover:border-primary hover:bg-secondary",
)}
>
<div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-secondary text-primary">
<Icon icon={icon} size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{title}</p>
<span
className={cn(
"mt-1 inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold",
BADGE_CLASS[status],
)}
>
{badge}
</span>
</div>
</Link>
);
}
@@ -0,0 +1,62 @@
import { cn } from "@/lib/utils";
interface DonutChartProps {
percent: number;
size?: number;
className?: string;
trackClassName?: string;
strokeClassName?: string;
}
export function DonutChart({
percent,
size = 128,
className,
trackClassName = "stroke-white/20",
strokeClassName = "stroke-white",
}: DonutChartProps) {
const r = 42;
const c = 2 * Math.PI * r;
const clamped = Math.min(100, Math.max(0, percent));
const offset = c - (clamped / 100) * c;
return (
<div className={cn("relative inline-flex", className)}>
<svg
width={size}
height={size}
viewBox="0 0 100 100"
className="-rotate-90"
aria-hidden
>
<circle
cx="50"
cy="50"
r={r}
fill="none"
strokeWidth="9"
className={trackClassName}
/>
<circle
cx="50"
cy="50"
r={r}
fill="none"
strokeWidth="9"
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
className={strokeClassName}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-2xl font-bold tabular-nums leading-none">
{Math.round(clamped)}%
</span>
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-wide opacity-80">
Complete
</span>
</div>
</div>
);
}
@@ -0,0 +1,102 @@
import { Link } from "react-router-dom";
import {
ArrowRight01Icon,
Link01Icon,
Settings02Icon,
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
import { Icon } from "@/components/icon";
import { cn } from "@/lib/utils";
export type ModuleStatus = "todo" | "progress" | "done";
const statusStyles: Record<
ModuleStatus,
{ dot: string; badge: string; label: string }
> = {
todo: {
dot: "bg-rose-500",
badge: "bg-rose-500/15 text-rose-400",
label: "Needs setup",
},
progress: {
dot: "bg-amber-400",
badge: "bg-amber-500/15 text-amber-400",
label: "In progress",
},
done: {
dot: "bg-sky-400",
badge: "bg-sky-500/15 text-sky-400",
label: "Active",
},
};
interface ModuleStripCardProps {
title: string;
subtitle: string;
icon: IconSvgElement;
href: string;
status: ModuleStatus;
meta?: string;
}
export function ModuleStripCard({
title,
subtitle,
icon,
href,
status,
meta = "Open in dashboard",
}: ModuleStripCardProps) {
const s = statusStyles[status];
return (
<Link
to={href}
className={cn(
"group flex min-w-[280px] flex-1 flex-col gap-4 rounded-2xl border border-border bg-card p-4 transition-colors",
"hover:border-primary hover:bg-secondary",
"sm:min-w-[300px] lg:min-w-0",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", s.dot)} />
<span className="text-xs text-muted-foreground">{subtitle}</span>
</div>
<div>
<h3 className="font-semibold leading-snug tracking-tight">{title}</h3>
<span
className={cn(
"mt-2 inline-flex rounded-md px-2 py-0.5 text-[11px] font-semibold",
s.badge,
)}
>
{s.label}
</span>
</div>
<div className="mt-auto flex items-center justify-between border-t border-border pt-3">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-secondary text-primary">
<Icon icon={icon} size={16} />
</div>
<span className="text-xs text-muted-foreground">{meta}</span>
</div>
<div className="flex items-center gap-3 text-muted-foreground">
<Icon icon={Link01Icon} size={14} className="opacity-60" />
<Icon
icon={Settings02Icon}
size={14}
className="opacity-60 group-hover:text-primary"
/>
<Icon
icon={ArrowRight01Icon}
size={14}
className="text-primary opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
</Link>
);
}
@@ -0,0 +1,163 @@
import { Link } from "react-router-dom";
import {
ArrowRight01Icon,
Download04Icon,
Rocket01Icon,
Settings02Icon,
} from "@hugeicons/core-free-icons";
import type { BotStats } from "@/api/client";
import { formatUptime } from "@/api/client";
import { Icon } from "@/components/icon";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface OverviewAsideProps {
stats: BotStats;
}
export function OverviewAside({ stats }: OverviewAsideProps) {
const activity = [
{
id: "ping",
label: "Gateway latency",
detail: `${stats.bot.ping}ms`,
badge: stats.bot.ping < 200 ? "Good" : "High",
badgeClass:
stats.bot.ping < 200
? "bg-emerald-500/15 text-emerald-400"
: "bg-amber-500/15 text-amber-400",
},
{
id: "uptime",
label: "Bot uptime",
detail: formatUptime(stats.bot.uptime),
badge: "Live",
badgeClass: "bg-primary/20 text-primary",
},
{
id: "ram",
label: "Memory usage",
detail: `${stats.hosting.ram_used} / ${stats.hosting.ram_total}`,
badge: `${stats.hosting.ram_percent}%`,
badgeClass: "bg-sky-500/15 text-sky-400",
},
];
return (
<aside className="hidden w-[min(100%,320px)] shrink-0 space-y-4 xl:block">
<Card className="border-border bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-semibold">
Connected servers ({stats.servers})
</CardTitle>
<Button variant="ghost" size="icon" className="size-7 text-muted-foreground">
<span className="text-lg leading-none">+</span>
</Button>
</CardHeader>
<CardContent className="pt-0">
<div className="flex -space-x-2">
{Array.from({ length: Math.min(6, stats.servers) }).map((_, i) => (
<Avatar
key={i}
className="size-9 border-2 border-card ring-0"
>
<AvatarImage src={stats.bot.avatar} alt="" />
<AvatarFallback className="bg-primary/25 text-[10px] text-primary">
{String(i + 1)}
</AvatarFallback>
</Avatar>
))}
{stats.servers > 6 ? (
<div className="flex size-9 items-center justify-center rounded-full border-2 border-card bg-secondary text-xs font-medium text-muted-foreground">
+{stats.servers - 6}
</div>
) : null}
</div>
<p className="mt-3 text-xs text-muted-foreground">
{stats.users.toLocaleString()} members across your guilds
</p>
</CardContent>
</Card>
<Card className="overflow-hidden border-border bg-secondary">
<CardContent className="p-5">
<div className="space-y-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Icon icon={Rocket01Icon} size={22} />
</div>
<div>
<p className="font-semibold">Keep SupportBot up to date</p>
<p className="mt-1 text-xs text-muted-foreground">
Check for releases and install updates from settings.
</p>
</div>
<Button asChild size="sm" className="w-full">
<Link to="/settings">
<Icon icon={Download04Icon} size={16} className="mr-2" />
Open settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold">System activity</CardTitle>
<Button variant="ghost" size="icon" className="size-7 text-muted-foreground">
<span className="text-base leading-none">⋯</span>
</Button>
</CardHeader>
<CardContent className="space-y-1 p-0 pb-2">
{activity.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-secondary/40"
>
<Avatar className="size-9">
<AvatarImage src={stats.bot.avatar} alt={stats.bot.username} />
<AvatarFallback className="bg-secondary text-xs">
SB
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.label}</p>
<p className="truncate text-xs text-muted-foreground">
{item.detail}
</p>
</div>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ${item.badgeClass}`}
>
{item.badge}
</span>
</div>
))}
<Button
asChild
variant="ghost"
className="mx-2 mt-1 w-[calc(100%-1rem)] justify-between text-xs text-muted-foreground"
>
<Link to="/system">
All system details
<Icon icon={ArrowRight01Icon} size={14} />
</Link>
</Button>
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-secondary text-primary">
<Icon icon={Settings02Icon} size={18} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">v{stats.bot.version}</p>
<p className="text-xs text-muted-foreground">CPU {stats.hosting.cpu_load}</p>
</div>
</CardContent>
</Card>
</aside>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from "react-router-dom";
import type { IconSvgElement } from "@hugeicons/react";
import { Icon } from "@/components/icon";
import { cn } from "@/lib/utils";
interface PluginCardProps {
title: string;
description: string;
icon: IconSvgElement;
href: string;
}
export function PluginCard({
title,
description,
icon,
href,
}: PluginCardProps) {
return (
<Link
to={href}
className={cn(
"group flex flex-col rounded-2xl border border-border bg-card p-5 transition-colors",
"hover:border-primary hover:bg-secondary",
)}
>
<div className="mb-4 flex size-10 items-center justify-center rounded-xl bg-secondary text-primary">
<Icon icon={icon} size={20} />
</div>
<h3 className="mb-2 font-semibold tracking-tight">{title}</h3>
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</Link>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Link } from "react-router-dom";
import { SparklesIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import { useBotStats } from "@/context/BotStatsContext";
export function PromoBanner() {
const { stats } = useBotStats();
const version = stats?.bot.version ?? "26.1.1";
return (
<div className="rounded-2xl border border-border bg-card p-6 md:p-8">
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="max-w-lg space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon icon={SparklesIcon} size={16} />
<span>SupportBot {version}</span>
</div>
<h2 className="text-2xl font-bold tracking-tight md:text-3xl">
Manage tickets, configs & transcripts
</h2>
<p className="text-sm text-muted-foreground md:text-base">
Configure your bot from one place. Edit settings visually, review
ticket transcripts, and monitor live stats.
</p>
<Button asChild variant="secondary" className="mt-2">
<Link to="/configs">Open settings</Link>
</Button>
</div>
<div className="hidden shrink-0 md:block">
<div className="flex size-36 items-center justify-center rounded-2xl border border-border bg-secondary text-6xl">
🤖
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { firstAllowedPath, hasPermission } from "@/lib/permissions";
export function RequirePermission({
permission,
children,
}: {
permission: string;
children: React.ReactNode;
}) {
const { loading, isAuthenticated, permissions } = useAuth();
if (loading) {
return (
<div className="flex flex-1 items-center justify-center py-16 text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (!hasPermission(permissions, permission)) {
return <Navigate to={firstAllowedPath(permissions)} replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { api } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/context/AuthContext";
export function DashboardApiSettings() {
const { hasPermission } = useAuth();
const canEdit = hasPermission("settings.update");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [config, setConfig] = useState<any>(null);
useEffect(() => {
api.getConfigJson("api")
.then((res) => {
if (res.data) setConfig(res.data);
})
.catch(() => setMessage("Failed to load API config"))
.finally(() => setLoading(false));
}, []);
async function saveConfig() {
if (!canEdit || !config) return;
setSaving(true);
setMessage("");
try {
// Create a flat update payload for updateConfigFields
const updates = {
"api:API.Port": config.API?.Port,
"api:API.EmeraldAPIKey": config.API?.EmeraldAPIKey,
"api:API.TrustProxy": config.API?.TrustProxy,
"api:API.OAuth.Enabled": config.API?.OAuth?.Enabled,
"api:API.OAuth.ClientId": config.API?.OAuth?.ClientId,
"api:API.OAuth.ClientSecret": config.API?.OAuth?.ClientSecret,
"api:API.OAuth.RedirectUri": config.API?.OAuth?.RedirectUri,
};
await api.updateConfigFields(updates);
setMessage("API settings saved and backend restarted.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (loading) return <div className="animate-pulse h-32 bg-secondary/20 rounded-md" />;
if (!config) return null;
return (
<div className="space-y-8">
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Core API Settings</p>
<p className="text-xs text-muted-foreground">
Configure the internal API that powers this dashboard.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>API Port</Label>
<Input
type="number"
value={config.API?.Port || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, Port: parseInt(e.target.value) || 3000 }})}
className="border-border bg-secondary/30"
/>
</div>
<div className="space-y-2">
<Label>Emerald API Key</Label>
<Input
type="password"
value={config.API?.EmeraldAPIKey || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, EmeraldAPIKey: e.target.value }})}
className="border-border bg-secondary/30"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Switch
checked={config.API?.TrustProxy || false}
disabled={!canEdit}
onCheckedChange={(c) => setConfig({ ...config, API: { ...config.API, TrustProxy: c }})}
/>
<Label className="text-sm">Trust Proxy (Enable if using Cloudflare/Nginx)</Label>
</div>
</div>
<div className="space-y-4 border-t border-border pt-6">
<div>
<p className="text-sm font-medium">OAuth Authentication</p>
<p className="text-xs text-muted-foreground">
Discord application credentials for dashboard login.
</p>
</div>
<div className="flex items-center gap-2 pb-2">
<Switch
checked={config.API?.OAuth?.Enabled || false}
disabled={!canEdit}
onCheckedChange={(c) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, Enabled: c } }})}
/>
<Label className="text-sm">Enable OAuth</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Client ID</Label>
<Input
value={config.API?.OAuth?.ClientId || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientId: e.target.value } }})}
className="border-border bg-secondary/30"
/>
</div>
<div className="space-y-2">
<Label>Client Secret</Label>
<Input
type="password"
value={config.API?.OAuth?.ClientSecret || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientSecret: e.target.value } }})}
className="border-border bg-secondary/30"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Redirect URI</Label>
<Input
value={config.API?.OAuth?.RedirectUri || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, RedirectUri: e.target.value } }})}
className="border-border bg-secondary/30"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Must match the OAuth2 callback URL registered in the Discord Developer Portal exactly.
</p>
</div>
</div>
</div>
{canEdit ? (
<div className="flex items-center gap-2 border-t border-border pt-6">
<Button
type="button"
disabled={saving}
onClick={() => void saveConfig()}
>
{saving ? "Saving…" : "Save API Config"}
</Button>
{message ? (
<span className="text-xs text-muted-foreground">{message}</span>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,278 @@
import { useEffect, useRef, useState } from "react";
import { ImageUpload01Icon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useBranding } from "@/context/BrandingContext";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Could not read file"));
reader.readAsDataURL(file);
});
}
interface DashboardBrandingSettingsProps {
canEdit: boolean;
}
export function DashboardDangerZone({ canEdit }: DashboardBrandingSettingsProps) {
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [open, setOpen] = useState(false);
async function resetDashboard() {
if (!canEdit) return;
setSaving(true);
setMessage("");
try {
const res = await api.resetDashboard();
setMessage(res.message || "Dashboard reset successfully.");
setTimeout(() => {
window.location.href = "/setup";
}, 1500);
} catch (err) {
setMessage(err instanceof Error ? err.message : "Reset failed");
setSaving(false);
}
}
if (!canEdit) return null;
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Reset the dashboard to factory settings. This clears the configuration and returns you to the initial setup screen.
</p>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="destructive">
Reset Dashboard
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will reset the dashboard to setup mode.
You will need to reconnect the Discord OAuth and reconfigure the dashboard owners.
</DialogDescription>
</DialogHeader>
{message && <p className="text-sm font-medium text-destructive">{message}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void resetDashboard()} disabled={saving}>
{saving ? "Resetting..." : "Yes, reset dashboard"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettingsProps) {
const { branding, setBrandingLocal } = useBranding();
const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(branding.title);
const [pageTitle, setPageTitle] = useState(branding.pageTitle);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
setTitle(branding.title);
setPageTitle(branding.pageTitle);
}, [branding.title, branding.pageTitle]);
const faviconPreview =
branding.hasFavicon && branding.faviconUrl
? `${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`
: null;
async function saveText() {
if (!canEdit) return;
setSaving(true);
setMessage("");
try {
const res = await api.updateBranding({ title, pageTitle });
if (res.data) setBrandingLocal(res.data);
setMessage(res.message || "Branding saved.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
async function onFaviconSelected(file: File | null) {
if (!canEdit || !file) return;
setUploading(true);
setMessage("");
try {
const dataUrl = await readFileAsDataUrl(file);
const res = await api.uploadBrandingFavicon(dataUrl);
if (res.data) setBrandingLocal(res.data);
setMessage(res.message || "Favicon uploaded.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
}
async function removeFavicon() {
if (!canEdit) return;
setUploading(true);
setMessage("");
try {
const res = await api.removeBrandingFavicon();
if (res.data) setBrandingLocal(res.data);
setMessage(res.message || "Favicon removed.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Remove failed");
} finally {
setUploading(false);
}
}
const textDirty =
title.trim() !== branding.title || pageTitle.trim() !== branding.pageTitle;
return (
<div className="space-y-8">
{/* Text Branding */}
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Branding Text</p>
<p className="text-xs text-muted-foreground">
Sidebar title and browser tab title — saved on the server for everyone using this dashboard.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="brand-title">Sidebar title</Label>
<Input
id="brand-title"
value={title}
disabled={!canEdit}
maxLength={64}
onChange={(e) => setTitle(e.target.value)}
className="border-border bg-secondary/30"
placeholder="SupportBot"
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-page-title">Browser tab title</Label>
<Input
id="brand-page-title"
value={pageTitle}
disabled={!canEdit}
maxLength={80}
onChange={(e) => setPageTitle(e.target.value)}
className="border-border bg-secondary/30"
placeholder="SupportBot Dashboard"
/>
</div>
</div>
{canEdit ? (
<div className="flex items-center gap-2">
<Button
type="button"
disabled={saving || !textDirty}
onClick={() => void saveText()}
>
{saving ? "Saving…" : "Save titles"}
</Button>
{message && textDirty ? (
<span className="text-xs text-muted-foreground">{message}</span>
) : null}
</div>
) : null}
</div>
{/* Favicon Branding */}
<div className="space-y-4 border-t border-border pt-6">
<div>
<p className="text-sm font-medium">Favicon</p>
<p className="text-xs text-muted-foreground">
PNG, ICO, SVG, JPEG, or WebP — max 512 KB. Shown in the browser tab and beside the sidebar title.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex size-16 items-center justify-center overflow-hidden rounded-lg border border-border bg-secondary/30">
{faviconPreview ? (
<img
src={faviconPreview}
alt="Favicon preview"
className="size-full object-contain p-2"
/>
) : (
<Icon
icon={ImageUpload01Icon}
size={24}
className="text-muted-foreground/50"
/>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,image/webp,.ico"
className="hidden"
disabled={!canEdit || uploading}
onChange={(e) => void onFaviconSelected(e.target.files?.[0] ?? null)}
/>
<div className="flex flex-col gap-2">
{canEdit ? (
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "Uploading…" : "Upload Favicon"}
</Button>
{branding.hasFavicon ? (
<Button
type="button"
variant="destructive"
disabled={uploading}
onClick={() => void removeFavicon()}
>
Remove
</Button>
) : null}
</div>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
{!textDirty && message ? (
<span className="text-xs text-muted-foreground">{message}</span>
) : null}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,845 @@
import type { IconSvgElement } from "@hugeicons/react";
import {
ArrowLeft01Icon,
ArrowRight01Icon,
BotIcon,
CheckmarkCircle02Icon,
Copy01Icon,
Database01Icon,
Key01Icon,
Link01Icon,
SecurityLockIcon,
UserMultiple02Icon,
} from "@hugeicons/core-free-icons";
import type { ReactNode } from "react";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { FieldValidationResult, SetupFormValues } from "@/lib/setup-types";
import { cn } from "@/lib/utils";
export const SETUP_STEP_IDS = [
"botToken",
"database",
"secretKey",
"oauth",
"redirectUri",
"owners",
"emeraldApi",
] as const;
export type SetupStepId = (typeof SETUP_STEP_IDS)[number];
export const SETUP_STEPS: {
id: SetupStepId;
title: string;
subtitle: string;
icon: IconSvgElement;
}[] = [
{
id: "botToken",
title: "Bot token",
subtitle: "Connect your Discord bot",
icon: BotIcon,
},
{
id: "database",
title: "Database",
subtitle: "SQLite or MySQL engine",
icon: Database01Icon,
},
{
id: "secretKey",
title: "Session secret",
subtitle: "Secure dashboard logins",
icon: SecurityLockIcon,
},
{
id: "oauth",
title: "Discord OAuth",
subtitle: "Sign in with Discord",
icon: Key01Icon,
},
{
id: "redirectUri",
title: "Redirect URL",
subtitle: "OAuth callback address",
icon: Link01Icon,
},
{
id: "owners",
title: "Dashboard owners",
subtitle: "Who can access this panel",
icon: UserMultiple02Icon,
},
{
id: "emeraldApi",
title: "Emerald API",
subtitle: "One-click addons",
icon: Key01Icon,
},
];
function validationKeyForStep(id: SetupStepId): string {
if (id === "oauth") return "oauth";
if (id === "owners") return "owners";
return id;
}
export function SetupWizardShell({
brandingTitle,
faviconUrl,
faviconVersion,
hasFavicon,
children,
}: {
brandingTitle: string;
faviconUrl?: string | null;
faviconVersion?: number;
hasFavicon: boolean;
children: ReactNode;
}) {
return (
<div className="min-h-svh bg-background">
<div className="mx-auto flex min-h-svh max-w-6xl flex-col px-4 py-6 md:px-8 md:py-10">
<header className="mb-8 flex items-center gap-4">
{hasFavicon && faviconUrl ? (
<img
src={`${faviconUrl}${faviconVersion ? `?v=${faviconVersion}` : ""}`}
alt=""
className="size-12 rounded-2xl border border-border object-contain"
/>
) : (
<div className="flex size-12 items-center justify-center rounded-2xl border border-border bg-primary text-primary-foreground">
<Icon icon={BotIcon} size={24} />
</div>
)}
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
First-time setup
</p>
<h1 className="text-2xl font-semibold tracking-tight md:text-3xl">
{brandingTitle}
</h1>
</div>
</header>
{children}
</div>
</div>
);
}
export function SetupProgress({
currentIndex,
validation,
}: {
currentIndex: number;
validation: Record<string, FieldValidationResult>;
}) {
const progress = ((currentIndex + 1) / SETUP_STEPS.length) * 100;
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Step {currentIndex + 1} of {SETUP_STEPS.length}
</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<nav className="hidden space-y-1 md:block" aria-label="Setup steps">
{SETUP_STEPS.map((step, index) => {
const key = validationKeyForStep(step.id);
const verified = validation[key]?.ok;
const isCurrent = index === currentIndex;
const isPast = index < currentIndex;
return (
<div
key={step.id}
className={cn(
"flex items-start gap-3 rounded-xl border px-3 py-3 transition-colors",
isCurrent && "border-primary bg-secondary",
!isCurrent && "border-transparent",
isPast && !isCurrent && "opacity-80",
)}
>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
verified
? "bg-emerald-500/15 text-emerald-500"
: isCurrent
? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground",
)}
>
{verified ? (
<Icon icon={CheckmarkCircle02Icon} size={18} />
) : (
<Icon icon={step.icon} size={18} />
)}
</div>
<div className="min-w-0 pt-0.5">
<p
className={cn(
"text-sm font-medium leading-none",
isCurrent && "text-foreground",
!isCurrent && "text-muted-foreground",
)}
>
{step.title}
</p>
<p className="mt-1 text-xs text-muted-foreground">{step.subtitle}</p>
</div>
</div>
);
})}
</nav>
<p className="hidden text-xs leading-relaxed text-muted-foreground md:block">
Each step can be tested with Discord before you continue. You can move back
anytime to change a value.
</p>
</div>
);
}
export function SetupStepCard({
step,
children,
footer,
}: {
step: (typeof SETUP_STEPS)[number];
children: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="flex min-h-[min(520px,70vh)] flex-col rounded-2xl border border-border bg-card">
<div className="border-b border-border px-6 py-5 md:px-8">
<div className="flex items-center gap-3 md:hidden">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Icon icon={step.icon} size={20} />
</div>
<div>
<h2 className="text-lg font-semibold">{step.title}</h2>
<p className="text-sm text-muted-foreground">{step.subtitle}</p>
</div>
</div>
<div className="hidden md:block">
<h2 className="text-xl font-semibold tracking-tight">{step.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{step.subtitle}</p>
</div>
</div>
<div className="flex flex-1 flex-col px-6 py-6 md:px-8">{children}</div>
{footer ? (
<div className="border-t border-border px-6 py-4 md:px-8">{footer}</div>
) : null}
</div>
);
}
export function ValidationBanner({
result,
}: {
result?: FieldValidationResult;
}) {
if (!result) return null;
if (result.ok) {
return (
<Alert className="border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<AlertDescription className="space-y-2">
{result.bot ? (
<p>
Connected as <strong>@{result.bot.username}</strong>
{result.applicationId ? (
<span className="text-emerald-600/80 dark:text-emerald-400/80">
{" "}
· App ID {result.applicationId}
</span>
) : null}
</p>
) : null}
{result.users?.length ? (
<ul className="space-y-1">
{result.users.map((u) => (
<li key={u.id} className="flex items-center gap-2">
<Icon icon={CheckmarkCircle02Icon} size={14} />
{u.globalName || u.username}
<span className="font-mono text-xs opacity-80">{u.id}</span>
</li>
))}
</ul>
) : null}
{!result.bot && !result.users?.length ? (
<p>{result.warning || "Verified successfully."}</p>
) : null}
</AlertDescription>
</Alert>
);
}
return (
<Alert variant="destructive">
<AlertDescription>{result.error}</AlertDescription>
</Alert>
);
}
export function BotTokenStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Open the{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
Discord Developer Portal
</a>
, select your application, go to <strong>Bot</strong>, and reset or copy
the token.
</p>
<div className="space-y-2">
<Label htmlFor="botToken">Bot token</Label>
<Input
id="botToken"
type="password"
autoComplete="off"
value={values.botToken}
onChange={(e) => setValues({ ...values, botToken: e.target.value })}
placeholder="MTI…"
className="h-11 font-mono text-sm"
/>
</div>
<ValidationBanner result={validation.botToken} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating || !values.botToken}
onClick={onValidate}
>
{validating ? "Checking with Discord…" : "Verify bot token"}
</Button>
</div>
</div>
);
}
export function SecretKeyStep({
values,
setValues,
validation,
validating,
onValidate,
onGenerate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
onGenerate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
This random string signs your login cookies. Store it like a password — do
not commit it to public repos.
</p>
<div className="space-y-2">
<Label htmlFor="secretKey">Session secret</Label>
<Input
id="secretKey"
type="password"
autoComplete="off"
value={values.secretKey}
onChange={(e) => setValues({ ...values, secretKey: e.target.value })}
className="h-11 font-mono text-sm"
/>
</div>
<ValidationBanner result={validation.secretKey} />
<div className="mt-auto flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={onGenerate}>
Generate secure secret
</Button>
<Button
type="button"
variant="secondary"
disabled={validating || !values.secretKey}
onClick={onValidate}
>
{validating ? "Checking…" : "Validate"}
</Button>
</div>
</div>
);
}
export function OAuthStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Use the <strong>same application</strong> as your bot. Under OAuth2, copy
the Client ID and Client Secret.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="clientId">Client ID</Label>
<Input
id="clientId"
value={values.clientId}
onChange={(e) => setValues({ ...values, clientId: e.target.value })}
className="h-11 font-mono text-sm"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="clientSecret">Client Secret</Label>
<Input
id="clientSecret"
type="password"
autoComplete="off"
value={values.clientSecret}
onChange={(e) =>
setValues({ ...values, clientSecret: e.target.value })
}
className="h-11 font-mono text-sm"
/>
</div>
</div>
<ValidationBanner result={validation.oauth} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating || !values.clientId || !values.clientSecret}
onClick={onValidate}
>
{validating ? "Checking with Discord…" : "Verify OAuth credentials"}
</Button>
</div>
</div>
);
}
export function RedirectStep({
values,
setValues,
validation,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
}) {
async function copyUri() {
if (!values.redirectUri) return;
try {
await navigator.clipboard.writeText(values.redirectUri);
} catch {
/* ignore */
}
}
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Add this exact URL under <strong>OAuth2 → Redirects</strong> in the
Developer Portal. It must match character-for-character.
</p>
<div className="space-y-2">
<Label htmlFor="redirectUri">Redirect URI</Label>
<div className="flex gap-2">
<Input
id="redirectUri"
value={values.redirectUri}
onChange={(e) =>
setValues({ ...values, redirectUri: e.target.value })
}
className="h-11 flex-1 font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="icon"
className="size-11 shrink-0"
onClick={() => void copyUri()}
title="Copy URL"
>
<Icon icon={Copy01Icon} size={18} />
</Button>
</div>
</div>
{validation.redirectUri && !validation.redirectUri.ok ? (
<ValidationBanner result={validation.redirectUri} />
) : (
<Alert className="border-border bg-secondary">
<AlertDescription className="text-sm text-muted-foreground">
We pre-filled this from your current dashboard URL. If you access the
panel from a different domain later, update this and add the new URL in
Discord too.
</AlertDescription>
</Alert>
)}
</div>
);
}
export function OwnersStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Enable <strong>Developer Mode</strong> in Discord (Settings → Advanced),
then right-click your profile and <strong>Copy User ID</strong>. Add one ID
per line.
</p>
<div className="space-y-2">
<Label htmlFor="owners">Owner user IDs</Label>
<Textarea
id="owners"
value={values.ownerUserIds}
onChange={(e) => setValues({ ...values, ownerUserIds: e.target.value })}
placeholder={"829112572816130058\n987654321098765432"}
className="min-h-[120px] font-mono text-sm"
/>
</div>
<ValidationBanner result={validation.owners} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating || !values.ownerUserIds.trim()}
onClick={onValidate}
>
{validating ? "Checking with Discord…" : "Verify owner IDs"}
</Button>
</div>
</div>
);
}
export function SetupCompleteScreen({ onContinue }: { onContinue: () => void }) {
return (
<div className="mx-auto flex w-full max-w-lg flex-1 flex-col justify-center py-8">
<div className="rounded-2xl border border-border bg-card px-6 py-10 text-center md:px-10">
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon icon={CheckmarkCircle02Icon} size={40} />
</div>
<h2 className="text-2xl font-semibold tracking-tight">Setup complete</h2>
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">
Your configuration has been saved. Restart the server for changes to take
effect — stop the running process, then start it again (for example{" "}
<code className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs">
npm start
</code>
).
</p>
<Alert className="mt-6 text-left">
<AlertDescription>
After restarting, sign in with Discord to open the dashboard.
</AlertDescription>
</Alert>
<Button type="button" size="lg" className="mt-8 min-w-[220px]" onClick={onContinue}>
Continue to sign in
</Button>
</div>
</div>
);
}
export function SetupNav({
currentIndex,
validating,
saving,
onBack,
onNext,
onFinish,
}: {
currentIndex: number;
validating: boolean;
saving: boolean;
onBack: () => void;
onNext: () => void;
onFinish: () => void;
}) {
const isLast = currentIndex === SETUP_STEPS.length - 1;
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<Button
type="button"
variant="ghost"
disabled={currentIndex === 0 || saving}
onClick={onBack}
>
<Icon icon={ArrowLeft01Icon} size={18} className="mr-1" />
Back
</Button>
<div className="flex flex-1 justify-end gap-2">
{isLast ? (
<Button
type="button"
size="lg"
disabled={saving || validating}
onClick={onFinish}
className="min-w-[200px]"
>
{saving ? "Saving…" : "Finish setup"}
</Button>
) : (
<Button
type="button"
size="lg"
disabled={validating || saving}
onClick={onNext}
>
Continue
<Icon icon={ArrowRight01Icon} size={18} className="ml-1" />
</Button>
)}
</div>
</div>
);
}
export function EmeraldApiKeyStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
An Emerald Services API key is optional, but required to use the one-click addon installer in the dashboard. You can get yours from the{" "}
<a
href="https://emeraldsrv.dev"
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
Emerald Services Marketplace
</a>.
</p>
<div className="space-y-2">
<Label htmlFor="emeraldApiKey">Emerald API key (Optional)</Label>
<Input
id="emeraldApiKey"
type="password"
autoComplete="off"
value={values.emeraldApiKey}
onChange={(e) => setValues({ ...values, emeraldApiKey: e.target.value })}
placeholder="em_..."
className="h-11 font-mono text-sm"
/>
</div>
<ValidationBanner result={validation.emeraldApi} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating}
onClick={onValidate}
>
{validating ? "Checking…" : (values.emeraldApiKey ? "Verify key" : "Skip step")}
</Button>
</div>
</div>
);
}
export function DatabaseStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: React.Dispatch<React.SetStateAction<SetupFormValues>>;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
const result = validation.database;
const isMysql = values.dbDriver === "mysql";
return (
<div className="flex flex-1 flex-col gap-6">
<p className="text-sm leading-relaxed text-muted-foreground">
Choose your database storage engine. Select <strong>SQLite</strong> for instant zero-config local storage, or <strong>MySQL / MariaDB</strong> for external host databases.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div
onClick={() => setValues((v) => ({ ...v, dbDriver: "sqlite" }))}
className={cn(
"cursor-pointer rounded-xl border p-4 transition-all hover:border-primary",
values.dbDriver === "sqlite"
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border bg-card",
)}
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-foreground">SQLite</span>
<span className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-500">
Zero Config
</span>
</div>
<p className="text-xs text-muted-foreground">
Single-file SQL database stored locally at <code className="font-mono">./Data/supportbot.db</code>. Zero extra setup required.
</p>
</div>
<div
onClick={() => setValues((v) => ({ ...v, dbDriver: "mysql" }))}
className={cn(
"cursor-pointer rounded-xl border p-4 transition-all hover:border-primary",
values.dbDriver === "mysql"
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border bg-card",
)}
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-foreground">MySQL / MariaDB</span>
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-500">
External Database
</span>
</div>
<p className="text-xs text-muted-foreground">
Connect to a remote or local MySQL database server.
</p>
</div>
</div>
{isMysql && (
<div className="space-y-4 rounded-xl border border-border bg-card/50 p-4">
<h4 className="text-sm font-semibold text-foreground">MySQL Connection Settings</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dbHost">Database Host</Label>
<Input
id="dbHost"
placeholder="127.0.0.1 or localhost"
value={values.dbHost}
onChange={(e) => setValues((v) => ({ ...v, dbHost: e.target.value }))}
className="h-11 text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dbPort">Port</Label>
<Input
id="dbPort"
placeholder="3306"
value={values.dbPort}
onChange={(e) => setValues((v) => ({ ...v, dbPort: e.target.value }))}
className="h-11 text-sm font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dbDatabase">Database Name</Label>
<Input
id="dbDatabase"
placeholder="supportbot"
value={values.dbDatabase}
onChange={(e) => setValues((v) => ({ ...v, dbDatabase: e.target.value }))}
className="h-11 text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dbUser">Database User</Label>
<Input
id="dbUser"
placeholder="root"
value={values.dbUser}
onChange={(e) => setValues((v) => ({ ...v, dbUser: e.target.value }))}
className="h-11 text-sm font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dbPassword">Password</Label>
<Input
id="dbPassword"
type="password"
placeholder="••••••••••••"
value={values.dbPassword}
onChange={(e) => setValues((v) => ({ ...v, dbPassword: e.target.value }))}
className="h-11 text-sm font-mono"
/>
</div>
</div>
</div>
)}
<ValidationBanner result={result} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating}
onClick={onValidate}
>
{validating ? "Testing Connection..." : "Test Connection & Validate"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,240 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FloppyDiskIcon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { ConfigFieldInput } from "@/components/config/config-field";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { getByPath, setByPath } from "@/lib/config-utils";
import { transcriptTemplateSchema } from "@/lib/transcript-template-schema";
import { normalizeGoogleFontValue } from "@/lib/google-fonts";
import type { TranscriptTemplate } from "@/lib/transcript-template-types";
import { useAuth } from "@/context/AuthContext";
import { cn } from "@/lib/utils";
const PREVIEW_DEBOUNCE_MS = 450;
function canEditTemplate(role: string | undefined, hasTranscripts: boolean) {
return hasTranscripts && role !== "viewer";
}
export function TranscriptAppearanceEditor() {
const { user, hasPermission } = useAuth();
const readOnly = !canEditTemplate(user?.role, hasPermission("transcripts"));
const [data, setData] = useState<TranscriptTemplate | null>(null);
const [original, setOriginal] = useState<TranscriptTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [previewHtml, setPreviewHtml] = useState("");
const [previewLoading, setPreviewLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getTranscriptTemplate();
const tpl = (res.data ?? null) as TranscriptTemplate | null;
if (tpl) {
const normalized = {
...tpl,
Typography: {
...tpl.Typography,
FontFamily: normalizeGoogleFontValue(tpl.Typography?.FontFamily),
},
};
setData(normalized);
setOriginal(structuredClone(normalized));
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load template");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const refreshPreview = useCallback(async (tpl: TranscriptTemplate) => {
setPreviewLoading(true);
try {
const html = await api.previewTranscriptTemplate(tpl);
setPreviewHtml(html);
} catch {
setPreviewHtml(
"<!DOCTYPE html><body style='padding:2rem;font-family:sans-serif'><p>Preview failed to load.</p></body>",
);
} finally {
setPreviewLoading(false);
}
}, []);
useEffect(() => {
if (!data) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void refreshPreview(data);
}, PREVIEW_DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [data, refreshPreview]);
function patch(path: string, value: unknown) {
setData((prev) =>
prev ? (setByPath(prev as Record<string, unknown>, path, value) as TranscriptTemplate) : prev,
);
}
async function handleSave() {
if (readOnly || !data) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await api.saveTranscriptTemplate(data);
setMessage(res.message || "Saved.");
setOriginal(structuredClone(data));
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
const dirty =
data && original && JSON.stringify(data) !== JSON.stringify(original);
if (loading) {
return (
<div className="grid gap-4 lg:grid-cols-2">
<Skeleton className="h-96 rounded-xl" />
<Skeleton className="h-96 rounded-xl" />
</div>
);
}
if (!data) {
return (
<Alert variant="destructive">
<AlertDescription>{error || "Failed to load template"}</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Customize how new ticket transcripts look. Existing saved transcripts are not
changed.
</p>
<div className="flex items-center gap-2">
{dirty ? (
<span className="text-xs text-amber-500">Unsaved changes</span>
) : null}
{!readOnly ? (
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
{saving ? "Saving…" : "Save appearance"}
</Button>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert className="border-primary/30 bg-primary/10 text-primary">
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
<div className="grid min-h-0 gap-4 xl:grid-cols-[minmax(320px,420px)_1fr]">
<ScrollableEditor data={data} readOnly={readOnly} onPatch={patch} />
<Card className="flex min-h-[520px] flex-col overflow-hidden border-border bg-card">
<CardHeader className="pb-2">
<CardTitle className="text-base">Live preview</CardTitle>
<CardDescription>
Sample ticket with your current settings
{previewLoading ? " · updating…" : ""}
</CardDescription>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-4 pt-0">
<iframe
title="Transcript template preview"
srcDoc={previewHtml}
className={cn(
"h-[min(640px,calc(100svh-14rem))] w-full rounded-lg border border-border bg-background",
previewLoading && "opacity-60",
)}
sandbox="allow-same-origin"
/>
</CardContent>
</Card>
</div>
</div>
);
}
function ScrollableEditor({
data,
readOnly,
onPatch,
}: {
data: TranscriptTemplate;
readOnly: boolean;
onPatch: (path: string, value: unknown) => void;
}) {
return (
<div className="max-h-[min(720px,calc(100svh-12rem))] space-y-4 overflow-y-auto pr-1">
{transcriptTemplateSchema.map((section) => (
<Card key={section.title} className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description ? (
<CardDescription>{section.description}</CardDescription>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{section.fields.map((field) => (
<div
key={field.path}
className={
field.type === "textarea" ? "sm:col-span-2" : ""
}
>
<ConfigFieldInput
field={field}
value={getByPath(data as Record<string, unknown>, field.path)}
readOnly={readOnly}
onChange={(v) => onPatch(field.path, v)}
/>
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useState } from "react";
import { FloppyDiskIcon, RefreshIcon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/context/AuthContext";
import { cn } from "@/lib/utils";
interface TranscriptSettingsState {
autoDelete: { enabled: boolean; afterDays: number };
publicAccess: { enabled: boolean };
}
function canManageTranscripts(role: string | undefined, hasTranscripts: boolean) {
return hasTranscripts && role !== "viewer";
}
export function TranscriptSettingsPanel() {
const { user, hasPermission } = useAuth();
const readOnly = !canManageTranscripts(user?.role, hasPermission("transcripts"));
const [settings, setSettings] = useState<TranscriptSettingsState | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [purging, setPurging] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getTranscriptSettings();
const data = res.data;
if (data) {
setSettings({
autoDelete: {
enabled: Boolean(data.autoDelete?.enabled),
afterDays: data.autoDelete?.afterDays ?? 30,
},
publicAccess: {
enabled: Boolean(data.publicAccess?.enabled),
},
});
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load settings");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function handleSave() {
if (!settings || readOnly) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await api.updateTranscriptSettings(settings);
if (res.data) {
setSettings({
autoDelete: {
enabled: res.data.autoDelete.enabled,
afterDays: res.data.autoDelete.afterDays,
},
publicAccess: {
enabled: res.data.publicAccess.enabled,
},
});
}
setMessage(res.message ?? "Settings saved.");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
async function handlePurge() {
if (readOnly) return;
setPurging(true);
setError("");
setMessage("");
try {
const res = await api.purgeTranscripts();
setMessage(
res.message ??
(res.data?.skipped
? "Auto-delete is disabled."
: `Removed ${res.data?.deleted ?? 0} transcript(s).`),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Purge failed");
} finally {
setPurging(false);
}
}
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
);
}
if (!settings) {
return (
<Alert variant="destructive">
<AlertDescription>Could not load transcript settings.</AlertDescription>
</Alert>
);
}
return (
<div className="mx-auto max-w-2xl space-y-4">
{readOnly ? (
<Alert>
<AlertDescription>
You have view-only access. An admin or editor can change these settings.
</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert>
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-base">Auto-delete</CardTitle>
<CardDescription>
Remove transcript files automatically after they reach a certain age. The
server checks hourly and whenever you open the library.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-1 size-4 rounded border-border accent-primary"
checked={settings.autoDelete.enabled}
disabled={readOnly}
onChange={(e) =>
setSettings((s) =>
s
? {
...s,
autoDelete: { ...s.autoDelete, enabled: e.target.checked },
}
: s,
)
}
/>
<span className="text-sm">
<span className="font-medium">Enable auto-delete</span>
<span className="mt-0.5 block text-muted-foreground">
Deletes HTML files in Data/Transcripts based on last modified time.
</span>
</span>
</label>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="transcript-after-days">
Delete after (days)
</label>
<Input
id="transcript-after-days"
type="number"
min={1}
max={3650}
disabled={readOnly || !settings.autoDelete.enabled}
value={settings.autoDelete.afterDays}
onChange={(e) =>
setSettings((s) =>
s
? {
...s,
autoDelete: {
...s.autoDelete,
afterDays: Math.min(
3650,
Math.max(1, parseInt(e.target.value, 10) || 1),
),
},
}
: s,
)
}
className="max-w-[8rem]"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={readOnly || purging}
onClick={() => void handlePurge()}
>
<Icon icon={RefreshIcon} size={16} className={cn(purging && "animate-spin")} />
Run cleanup now
</Button>
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-base">Public transcripts</CardTitle>
<CardDescription>
When enabled, anyone with the link can view a transcript at{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{typeof window !== "undefined"
? `${window.location.origin}/transcripts/:id`
: "/transcripts/:id"}
</code>{" "}
without signing in. Channel IDs are numeric but not secret—only enable if
you are comfortable sharing links.
</CardDescription>
</CardHeader>
<CardContent>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-1 size-4 rounded border-border accent-primary"
checked={settings.publicAccess.enabled}
disabled={readOnly}
onChange={(e) =>
setSettings((s) =>
s
? {
...s,
publicAccess: { enabled: e.target.checked },
}
: s,
)
}
/>
<span className="text-sm">
<span className="font-medium">Allow public transcript URLs</span>
<span className="mt-0.5 block text-muted-foreground">
Dashboard preview and download still require login.
</span>
</span>
</label>
</CardContent>
</Card>
{!readOnly ? (
<Button type="button" disabled={saving} onClick={() => void handleSave()}>
<Icon icon={FloppyDiskIcon} size={16} />
{saving ? "Saving…" : "Save settings"}
</Button>
) : null}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border bg-card text-card-foreground shadow-none",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
@@ -0,0 +1,113 @@
import { forwardRef, useMemo, useState } from "react";
import { HexColorPicker } from "react-colorful";
import { cn } from "@/lib/utils";
import { isValidHex, normalizeHex } from "@/lib/normalize-hex";
import { useForwardedRef } from "@/hooks/use-forwarded-ref";
import type { ButtonProps } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
const ColorPicker = forwardRef<
HTMLInputElement,
Omit<ButtonProps, "value" | "onChange" | "onBlur"> & ColorPickerProps
>(
(
{ disabled, value, onChange, onBlur, name, className, size, ...props },
forwardedRef,
) => {
const ref = useForwardedRef(forwardedRef);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState("");
const parsedValue = useMemo(
() => normalizeHex(value, "#ffffff"),
[value],
);
const displayHex = open && draft ? draft : parsedValue;
function commitHex(raw: string) {
if (isValidHex(raw)) {
onChange(normalizeHex(raw));
}
setDraft("");
}
return (
<Popover
open={open}
onOpenChange={(next) => {
if (!next) commitHex(draft || parsedValue);
setOpen(next);
}}
>
<PopoverTrigger asChild disabled={disabled} onBlur={onBlur}>
<Button
{...props}
type="button"
className={cn(
"size-10 shrink-0 overflow-hidden rounded-md border border-border p-0 shadow-sm",
className,
)}
name={name}
onClick={() => setOpen(true)}
size={size ?? "icon"}
style={{ backgroundColor: parsedValue }}
variant="outline"
aria-label="Pick colour"
>
<span className="sr-only">{parsedValue}</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto border-border bg-card p-3"
align="start"
>
<HexColorPicker
className="color-picker-swatch"
color={parsedValue}
onChange={(hex) => {
setDraft("");
onChange(normalizeHex(hex));
}}
/>
<Input
ref={ref}
maxLength={7}
value={displayHex}
onChange={(e) => {
const v = e.currentTarget.value;
setDraft(v);
if (isValidHex(v)) {
onChange(normalizeHex(v));
}
}}
onBlur={() => commitHex(draft || parsedValue)}
onKeyDown={(e) => {
if (e.key === "Enter") {
commitHex(draft || parsedValue);
setOpen(false);
}
}}
className="mt-3 border-border bg-secondary/30 font-mono text-sm uppercase"
placeholder="#000000"
/>
</PopoverContent>
</Popover>
);
},
);
ColorPicker.displayName = "ColorPicker";
export { ColorPicker };
+153
View File
@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2 border-l border-l-transparent p-px",
orientation === "horizontal" &&
"h-2 flex-col border-t border-t-transparent p-px",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[hsl(240_4%_32%)] transition-colors hover:bg-[hsl(240_4%_44%)]" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+159
View File
@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+771
View File
@@ -0,0 +1,771 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+25
View File
@@ -0,0 +1,25 @@
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
function Toaster({ ...props }: ToasterProps) {
return (
<Sonner
theme="dark"
position="bottom-right"
richColors
closeButton
style={{ fontFamily: '"Poppins", system-ui, sans-serif' }}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
}
export { Toaster };
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+182
View File
@@ -0,0 +1,182 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api } from "../api/client";
import {
canEditConfig,
canViewConfig,
displayDiscordName,
hasPermission,
type DashboardGroup,
type DashboardPermissions,
type DashboardUser,
} from "../lib/permissions";
interface AuthContextValue {
user: DashboardUser | null;
permissions: DashboardPermissions | null;
isAuthenticated: boolean;
loading: boolean;
availableGroups: DashboardGroup[];
isImpersonating: boolean;
impersonatedGroup: DashboardGroup | null;
impersonateGroup: (groupId: string) => void;
stopImpersonation: () => void;
loginWithDiscord: () => void;
logout: () => Promise<void>;
refreshSession: () => Promise<boolean>;
hasPermission: (key: string) => boolean;
canViewConfig: (file: string) => boolean;
canEditConfig: (file: string) => boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const STORAGE_KEY = "nexus_impersonate_group";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<DashboardUser | null>(null);
const [groups, setGroups] = useState<DashboardGroup[]>([]);
const [loading, setLoading] = useState(true);
const [impersonatedGroupId, setImpersonatedGroupId] = useState<string | null>(
() => (typeof window !== "undefined" ? sessionStorage.getItem(STORAGE_KEY) : null)
);
const refreshSession = useCallback(async () => {
try {
const [userRes, groupsRes] = await Promise.all([
api.me(),
api.listGroups().catch(() => ({ data: { groups: [] } })),
]);
if (userRes.data) {
setUser(userRes.data);
if (groupsRes.data?.groups) {
setGroups(groupsRes.data.groups);
}
return true;
}
setUser(null);
return false;
} catch {
setUser(null);
return false;
}
}, []);
useEffect(() => {
refreshSession().finally(() => setLoading(false));
}, [refreshSession]);
const loginWithDiscord = useCallback(() => {
window.location.href = "/api/auth/discord";
}, []);
const logout = useCallback(async () => {
try {
if (typeof window !== "undefined") sessionStorage.removeItem(STORAGE_KEY);
setImpersonatedGroupId(null);
await api.logout();
} finally {
setUser(null);
}
}, []);
const impersonateGroup = useCallback((groupId: string) => {
if (typeof window !== "undefined") {
sessionStorage.setItem(STORAGE_KEY, groupId);
}
setImpersonatedGroupId(groupId);
}, []);
const stopImpersonation = useCallback(() => {
if (typeof window !== "undefined") {
sessionStorage.removeItem(STORAGE_KEY);
}
setImpersonatedGroupId(null);
}, []);
const impersonatedGroup = useMemo(
() => groups.find((g) => g.id === impersonatedGroupId) || null,
[groups, impersonatedGroupId]
);
const isImpersonating = Boolean(impersonatedGroupId && impersonatedGroup);
// Effective permissions: use impersonated group's permissions if active, else real user permissions
const permissions = useMemo(() => {
if (isImpersonating && impersonatedGroup) {
return impersonatedGroup.permissions;
}
return user?.permissions ?? null;
}, [isImpersonating, impersonatedGroup, user]);
const checkPermission = useCallback(
(key: string) => hasPermission(permissions, key),
[permissions]
);
const checkViewConfig = useCallback(
(file: string) => canViewConfig(permissions, file),
[permissions]
);
const checkEditConfig = useCallback(
(file: string) => canEditConfig(permissions, file),
[permissions]
);
const value = useMemo(
() => ({
user,
permissions,
isAuthenticated: !!user,
loading,
availableGroups: groups,
isImpersonating,
impersonatedGroup,
impersonateGroup,
stopImpersonation,
loginWithDiscord,
logout,
refreshSession,
hasPermission: checkPermission,
canViewConfig: checkViewConfig,
canEditConfig: checkEditConfig,
}),
[
user,
permissions,
loading,
groups,
isImpersonating,
impersonatedGroup,
impersonateGroup,
stopImpersonation,
loginWithDiscord,
logout,
refreshSession,
checkPermission,
checkViewConfig,
checkEditConfig,
]
);
return (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
export { displayDiscordName };
+64
View File
@@ -0,0 +1,64 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api, type BotStats } from "@/api/client";
interface BotStatsContextValue {
stats: BotStats | null;
loading: boolean;
error: string;
refresh: () => Promise<void>;
}
const BotStatsContext = createContext<BotStatsContextValue | null>(null);
export function BotStatsProvider({ children }: { children: ReactNode }) {
const [stats, setStats] = useState<BotStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
try {
const res = await api.stats();
if (res.data) {
setStats(res.data);
setError("");
} else if (res.error) {
setError(res.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load stats");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
}, [refresh]);
const value = useMemo(
() => ({ stats, loading, error, refresh }),
[stats, loading, error, refresh],
);
return (
<BotStatsContext.Provider value={value}>{children}</BotStatsContext.Provider>
);
}
export function useBotStats() {
const ctx = useContext(BotStatsContext);
if (!ctx) {
throw new Error("useBotStats must be used within BotStatsProvider");
}
return ctx;
}
+72
View File
@@ -0,0 +1,72 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api } from "@/api/client";
import { applyDashboardBranding } from "@/lib/apply-dashboard-branding";
import { DEFAULT_BRANDING, type DashboardBranding } from "@/lib/branding-types";
interface BrandingContextValue {
branding: DashboardBranding;
loading: boolean;
refreshBranding: () => Promise<void>;
setBrandingLocal: (next: DashboardBranding) => void;
}
const BrandingContext = createContext<BrandingContextValue | null>(null);
export function BrandingProvider({ children }: { children: ReactNode }) {
const [branding, setBranding] = useState<DashboardBranding>(DEFAULT_BRANDING);
const [loading, setLoading] = useState(true);
const refreshBranding = useCallback(async () => {
try {
const res = await api.getBranding();
if (res.data) {
setBranding(res.data);
applyDashboardBranding(res.data);
}
} catch {
setBranding(DEFAULT_BRANDING);
applyDashboardBranding(DEFAULT_BRANDING);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshBranding();
}, [refreshBranding]);
const setBrandingLocal = useCallback((next: DashboardBranding) => {
setBranding(next);
applyDashboardBranding(next);
}, []);
const value = useMemo(
() => ({
branding,
loading,
refreshBranding,
setBrandingLocal,
}),
[branding, loading, refreshBranding, setBrandingLocal],
);
return (
<BrandingContext.Provider value={value}>{children}</BrandingContext.Provider>
);
}
export function useBranding() {
const ctx = useContext(BrandingContext);
if (!ctx) {
throw new Error("useBranding must be used within BrandingProvider");
}
return ctx;
}
@@ -0,0 +1,101 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
api,
type DashboardNotification,
} from "@/api/client";
interface NotificationsContextValue {
items: DashboardNotification[];
unreadCount: number;
loading: boolean;
refresh: () => Promise<void>;
markAllRead: () => Promise<void>;
dismiss: (id: string) => Promise<void>;
}
const NotificationsContext = createContext<NotificationsContextValue | null>(
null,
);
export function NotificationsProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<DashboardNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await api.getNotifications();
if (res.data) {
setItems(res.data.items);
setUnreadCount(res.data.unreadCount);
}
} catch {
/* ignore when logged out or API down */
} finally {
setLoading(false);
}
}, []);
const markAllRead = useCallback(async () => {
try {
const res = await api.markNotificationsRead("all");
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(res.data?.unreadCount ?? 0);
} catch {
/* ignore */
}
}, []);
const dismiss = useCallback(
async (id: string) => {
try {
const res = await api.dismissNotification(id);
setItems((prev) => prev.filter((n) => n.id !== id));
setUnreadCount(res.data?.unreadCount ?? 0);
} catch {
/* ignore */
}
},
[],
);
useEffect(() => {
refresh();
const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
}, [refresh]);
const value = useMemo(
() => ({
items,
unreadCount,
loading,
refresh,
markAllRead,
dismiss,
}),
[items, unreadCount, loading, refresh, markAllRead, dismiss],
);
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationsContext);
if (!ctx) {
throw new Error("useNotifications must be used within NotificationsProvider");
}
return ctx;
}
+61
View File
@@ -0,0 +1,61 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api } from "@/api/client";
import type { SetupStatus } from "@/lib/setup-types";
interface SetupContextValue {
status: SetupStatus | null;
loading: boolean;
error: string;
complete: boolean;
refresh: () => Promise<void>;
}
const SetupContext = createContext<SetupContextValue | null>(null);
export function SetupProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<SetupStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
setError("");
try {
const res = await api.getSetupStatus();
setStatus(res.data ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load setup status");
setStatus(null);
}
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const value = useMemo(
() => ({
status,
loading,
error,
complete: Boolean(status?.complete),
refresh,
}),
[status, loading, error, refresh],
);
return <SetupContext.Provider value={value}>{children}</SetupContext.Provider>;
}
export function useSetup() {
const ctx = useContext(SetupContext);
if (!ctx) throw new Error("useSetup must be used within SetupProvider");
return ctx;
}
+26
View File
@@ -0,0 +1,26 @@
import { useCallback, useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { api } from "@/api/client";
export function useAddonConfigs() {
const location = useLocation();
const [files, setFiles] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await api.listAddonConfigs();
setFiles(res.data ?? []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh, location.pathname]);
return { files, loading, refresh };
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useRef, type ForwardedRef } from "react";
export function useForwardedRef<T>(forwardedRef: ForwardedRef<T>) {
const innerRef = useRef<T>(null);
useEffect(() => {
if (!forwardedRef) return;
if (typeof forwardedRef === "function") {
forwardedRef(innerRef.current);
} else {
forwardedRef.current = innerRef.current;
}
});
return innerRef;
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from "react";
export interface StreamMetrics {
timestamp: number;
ping: number;
ram_percent: number;
ram_used_mb: number;
ram_total_mb: number;
cpu_load: number;
uptime: number;
}
export interface StreamLogEntry {
type: "Output" | "Warn" | "Error";
timestamp: string;
message: string;
}
export interface StreamTicketMessage {
ticketId: string;
message: {
id: string;
author: {
id: string;
username: string;
globalName: string;
avatar: string;
bot: boolean;
isDashboard?: boolean;
};
content: string;
embeds?: { title?: string; description?: string; color?: string | null }[];
attachments?: { id: string; name: string; url: string; contentType?: string }[];
createdAt: number;
};
}
export interface StreamTicketMessageDeleted {
ticketId: string;
messageId: string;
}
export interface StreamTicketMessageUpdated {
ticketId: string;
messageId: string;
content: string;
}
export interface StreamTicketCreated {
ticket_id: string;
user_id: string;
subject: string;
description: string;
department: string;
priority: string;
created_at: number;
}
export interface StreamTicketClosed {
ticket_id: string;
}
export interface StreamHandlers {
onMetrics?: (metrics: StreamMetrics) => void;
onLog?: (log: StreamLogEntry) => void;
onTicketMessage?: (msg: StreamTicketMessage) => void;
onTicketMessageDeleted?: (data: StreamTicketMessageDeleted) => void;
onTicketMessageUpdated?: (data: StreamTicketMessageUpdated) => void;
onTicketCreated?: (ticket: StreamTicketCreated) => void;
onTicketClosed?: (ticket: StreamTicketClosed) => void;
}
export function useRealtimeStream(handlers: StreamHandlers) {
const [connected, setConnected] = useState(false);
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
let eventSource: EventSource | null = null;
let timerId: number | null = null;
const connect = () => {
eventSource = new EventSource("/api/stream");
eventSource.onopen = () => {
setConnected(true);
};
eventSource.addEventListener("connected", () => {
setConnected(true);
});
eventSource.addEventListener("metrics", (ev) => {
try {
const data: StreamMetrics = JSON.parse(ev.data);
handlersRef.current.onMetrics?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("log", (ev) => {
try {
const data: StreamLogEntry = JSON.parse(ev.data);
handlersRef.current.onLog?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_message", (ev) => {
try {
const data: StreamTicketMessage = JSON.parse(ev.data);
handlersRef.current.onTicketMessage?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_message_deleted", (ev) => {
try {
const data: StreamTicketMessageDeleted = JSON.parse(ev.data);
handlersRef.current.onTicketMessageDeleted?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_message_updated", (ev) => {
try {
const data: StreamTicketMessageUpdated = JSON.parse(ev.data);
handlersRef.current.onTicketMessageUpdated?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_created", (ev) => {
try {
const data: StreamTicketCreated = JSON.parse(ev.data);
handlersRef.current.onTicketCreated?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_closed", (ev) => {
try {
const data: StreamTicketClosed = JSON.parse(ev.data);
handlersRef.current.onTicketClosed?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.onerror = () => {
setConnected(false);
eventSource?.close();
timerId = window.setTimeout(connect, 4000);
};
};
connect();
return () => {
if (timerId) window.clearTimeout(timerId);
if (eventSource) eventSource.close();
};
}, []);
return { connected };
}
+149
View File
@@ -0,0 +1,149 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 4%;
--foreground: 0 0% 98%;
--card: 240 7% 11%;
--card-foreground: 0 0% 98%;
--popover: 240 8% 10%;
--popover-foreground: 0 0% 98%;
--primary: 141 45% 49%;
--primary-foreground: 0 0% 100%;
--secondary: 240 6% 14%;
--secondary-foreground: 0 0% 90%;
--muted: 240 6% 14%;
--muted-foreground: 240 5% 55%;
--accent: 141 29% 18%;
--accent-foreground: 141 52% 78%;
--destructive: 0 72% 55%;
--destructive-foreground: 0 0% 100%;
--border: 240 6% 18%;
--input: 240 6% 14%;
--ring: 141 45% 49%;
--radius: 0.75rem;
--chart-1: 141 45% 49%;
--chart-2: 199 89% 48%;
--chart-3: 38 92% 50%;
--chart-4: 280 65% 60%;
--chart-5: 0 72% 55%;
--sidebar-background: 240 10% 5%;
--sidebar-foreground: 240 5% 90%;
--sidebar-primary: 141 45% 49%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 141 26% 14%;
--sidebar-accent-foreground: 141 48% 82%;
--sidebar-border: 240 6% 14%;
--sidebar-ring: 141 45% 49%;
--scrollbar-size: 6px;
--scrollbar-thumb: 240 4% 32%;
--scrollbar-thumb-hover: 240 4% 44%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background font-sans text-foreground antialiased;
}
html {
color-scheme: dark;
}
html,
body,
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--scrollbar-thumb)) transparent;
}
*::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
}
*::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: hsl(var(--scrollbar-thumb));
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--scrollbar-thumb-hover));
}
*::-webkit-scrollbar-thumb:active {
background-color: hsl(var(--scrollbar-thumb-hover));
}
*::-webkit-scrollbar-corner {
background: transparent;
}
}
@layer utilities {
.dashboard-glow {
background: hsl(var(--background));
}
.promo-gradient {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
}
.overview-card-glow {
box-shadow: none;
}
.color-picker-swatch.react-colorful {
width: 220px;
height: 140px;
}
.color-picker-swatch .react-colorful__saturation {
border-radius: calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0 0;
}
.color-picker-swatch .react-colorful__hue {
height: 12px;
border-radius: 0 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px);
margin-top: 10px;
}
.color-picker-swatch .react-colorful__pointer {
width: 16px;
height: 16px;
}
.unicode-emoji-panel-host emoji-picker.unicode-emoji-picker-el {
width: 100%;
height: min(50vh, 380px);
--background: hsl(var(--card));
--border-color: hsl(var(--border));
--button-active-background: hsl(var(--primary) / 0.25);
--button-hover-background: hsl(var(--accent));
--input-border-color: hsl(var(--border));
--input-font-color: hsl(var(--foreground));
--input-placeholder-color: hsl(var(--muted-foreground));
--outline-color: hsl(var(--ring));
--category-font-color: hsl(var(--muted-foreground));
--indicator-color: hsl(var(--primary));
}
}
+128
View File
@@ -0,0 +1,128 @@
import { isValidHex, normalizeHex } from "@/lib/normalize-hex";
export const ACCENT_STORAGE_KEY = "supportbot-dashboard-accent";
export const DEFAULT_ACCENT_HEX = "#45b46c";
const CSS_VARS = [
"--primary",
"--ring",
"--chart-1",
"--sidebar-primary",
"--sidebar-ring",
"--accent",
"--accent-foreground",
"--sidebar-accent",
"--sidebar-accent-foreground",
] as const;
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const normalized = normalizeHex(hex, DEFAULT_ACCENT_HEX);
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
function hsl(h: number, s: number, l: number): string {
return `${h} ${s}% ${l}%`;
}
/** Apply accent colour to dashboard CSS variables (replaces default purple). */
export function applyAccentTheme(hex: string): string {
const normalized = normalizeHex(hex, DEFAULT_ACCENT_HEX);
const { h, s, l } = hexToHsl(normalized);
const root = document.documentElement;
const primaryS = Math.min(Math.max(s, 45), 85);
const primaryL = Math.min(Math.max(l, 42), 62);
root.style.setProperty("--primary", hsl(h, primaryS, primaryL));
root.style.setProperty("--ring", hsl(h, primaryS, primaryL));
root.style.setProperty("--chart-1", hsl(h, primaryS, primaryL));
root.style.setProperty("--sidebar-primary", hsl(h, primaryS, primaryL));
root.style.setProperty("--sidebar-ring", hsl(h, primaryS, primaryL));
root.style.setProperty("--primary-foreground", "0 0% 100%");
root.style.setProperty("--sidebar-primary-foreground", "0 0% 100%");
root.style.setProperty("--accent", hsl(h, Math.round(primaryS * 0.64), 18));
root.style.setProperty(
"--accent-foreground",
hsl(h, Math.min(Math.round(primaryS * 1.15), 90), 78),
);
root.style.setProperty("--sidebar-accent", hsl(h, Math.round(primaryS * 0.57), 14));
root.style.setProperty(
"--sidebar-accent-foreground",
hsl(h, Math.min(Math.round(primaryS * 1.07), 88), 82),
);
return normalized;
}
export function clearAccentTheme(): void {
for (const name of CSS_VARS) {
document.documentElement.style.removeProperty(name);
}
document.documentElement.style.removeProperty("--primary-foreground");
document.documentElement.style.removeProperty("--sidebar-primary-foreground");
}
export function getStoredAccentHex(): string | null {
try {
const raw = localStorage.getItem(ACCENT_STORAGE_KEY);
if (!raw || !isValidHex(raw)) return null;
return normalizeHex(raw);
} catch {
return null;
}
}
export function saveAccentTheme(hex: string): string {
const normalized = applyAccentTheme(hex);
try {
localStorage.setItem(ACCENT_STORAGE_KEY, normalized);
} catch {
/* ignore quota */
}
return normalized;
}
export function resetAccentTheme(): void {
try {
localStorage.removeItem(ACCENT_STORAGE_KEY);
} catch {
/* ignore */
}
clearAccentTheme();
}
export function initAccentTheme(): void {
const stored = getStoredAccentHex();
if (stored) applyAccentTheme(stored);
}
+25
View File
@@ -0,0 +1,25 @@
export const ADDON_CONFIG_PREFIX = "addon:";
export function addonConfigFileParam(filename: string): string {
return `${ADDON_CONFIG_PREFIX}${filename}`;
}
export function parseConfigFileParam(
file: string | null,
): { type: "main" | "addon"; file: string } | null {
if (!file) return null;
if (file.startsWith(ADDON_CONFIG_PREFIX)) {
return {
type: "addon",
file: file.slice(ADDON_CONFIG_PREFIX.length),
};
}
return { type: "main", file };
}
export function addonConfigLabel(filename: string): string {
return filename
.replace(/\.(yml|yaml|json)$/i, "")
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
@@ -0,0 +1,23 @@
import type { DashboardBranding } from "@/lib/branding-types";
const FAVICON_LINK_ID = "supportbot-dashboard-favicon";
export function applyDashboardBranding(branding: DashboardBranding) {
if (branding.pageTitle) {
document.title = branding.pageTitle;
}
let link = document.getElementById(FAVICON_LINK_ID) as HTMLLinkElement | null;
if (branding.faviconUrl) {
const href = `${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`;
if (!link) {
link = document.createElement("link");
link.id = FAVICON_LINK_ID;
link.rel = "icon";
document.head.appendChild(link);
}
link.href = href;
} else if (link) {
link.remove();
}
}
+15
View File
@@ -0,0 +1,15 @@
export interface DashboardBranding {
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}
export const DEFAULT_BRANDING: DashboardBranding = {
title: "SupportBot",
pageTitle: "SupportBot Dashboard",
hasFavicon: false,
faviconUrl: null,
updatedAt: null,
};
+29
View File
@@ -0,0 +1,29 @@
import type { ConfigSchema } from "./types";
import { messagesSchema } from "./messages";
import { supportbotSchema } from "./supportbot";
import { supportbotAiSchema } from "./supportbot-ai";
import { ticketPanelSchema } from "./ticket-panel";
export type ConfigFileName =
| "supportbot"
| "ticket-panel"
| "commands"
| "messages"
| "supportbot-ai";
export const configSchemas: Record<ConfigFileName, ConfigSchema | "commands"> = {
supportbot: supportbotSchema,
"ticket-panel": ticketPanelSchema,
commands: "commands",
messages: messagesSchema,
"supportbot-ai": supportbotAiSchema,
};
export function getSchema(file: string): ConfigSchema | "commands" | null {
if (file in configSchemas) {
return configSchemas[file as ConfigFileName];
}
return null;
}
export * from "./types";
@@ -0,0 +1,41 @@
import type { ConfigSchema } from "./types";
export const messagesSchema: ConfigSchema = [
{
title: "Info command",
fields: [
{ path: "Info.Title", label: "Title", type: "text" },
{ path: "Info.Description", label: "Description", type: "textarea" },
{ path: "Info.Button", label: "Button label", type: "text" },
{ path: "Info.URL", label: "Button URL", type: "text" },
{ path: "Info.Colour", label: "Colour", type: "color" },
],
},
{
title: "Welcome message",
fields: [
{ path: "Welcome.Enabled", label: "Enabled", type: "boolean" },
{
path: "Welcome.LayoutStyle",
label: "Layout style",
type: "select",
options: [
{ value: "modern", label: "Modern (components)" },
{ value: "classic", label: "Classic embed" },
],
},
{ path: "Welcome.Embed.Colour", label: "Embed colour", type: "color" },
{ path: "Welcome.Embed.Title", label: "Embed title", type: "text" },
{ path: "Welcome.Embed.Message", label: "Embed message", type: "textarea" },
],
},
{
title: "Leave message",
fields: [
{ path: "Leave.Enabled", label: "Enabled", type: "boolean" },
{ path: "Leave.Embed.Colour", label: "Embed colour", type: "color" },
{ path: "Leave.Embed.Title", label: "Embed title", type: "text" },
{ path: "Leave.Embed.Message", label: "Embed message", type: "textarea" },
],
},
];
@@ -0,0 +1,142 @@
import type { ConfigSchema } from "./types";
const providers = [
{ value: "openai", label: "OpenAI" },
{ value: "groq", label: "Groq" },
{ value: "claude", label: "Claude" },
];
const messageTypes = [
{ value: "text", label: "Plain text" },
{ value: "embed", label: "Embed" },
{ value: "components_v2", label: "Components v2" },
];
const memoryScopes = [
{ value: "channel", label: "Per channel" },
{ value: "guild", label: "Per server" },
];
export const supportbotAiSchema: ConfigSchema = [
{
title: "General",
fields: [
{ path: "Enabled", label: "AI enabled", type: "boolean" },
{
path: "General.Provider",
label: "Provider",
type: "select",
options: providers,
},
{ path: "General.Model", label: "Model", type: "text" },
{
path: "General.Model_API_Key",
label: "API key",
type: "secret",
placeholder: "MODEL_API_KEY",
},
{ path: "General.Tokens", label: "Max tokens", type: "number" },
{
path: "General.Temperature",
label: "Temperature",
type: "number",
},
{ path: "General.Name", label: "Display name", type: "text" },
{
path: "General.PastebinAPI_Key",
label: "Pastebin API key",
type: "secret",
placeholder: "PASTEBIN_API_KEY",
},
{
path: "General.PastebinAPI_URL",
label: "Pastebin API URL",
type: "text",
},
],
},
{
title: "Style & channel",
fields: [
{
path: "Style.MessageType",
label: "Message type",
type: "select",
options: messageTypes,
},
{
path: "Features.SuggestTickets",
label: "Suggest tickets",
type: "boolean",
},
{
path: "Channels.AIChannel",
label: "AI channel ID",
type: "discordId",
},
{ path: "Embed.Color", label: "Embed colour", type: "color" },
{ path: "Embed.Footer", label: "Embed footer", type: "text" },
],
},
{
title: "Memory",
fields: [
{ path: "Memory.Enabled", label: "Memory enabled", type: "boolean" },
{ path: "Memory.StoreFacts", label: "Store facts", type: "boolean" },
{
path: "Memory.StoreSummaries",
label: "Store summaries",
type: "boolean",
},
{
path: "Memory.MaxRecentMessages",
label: "Max recent messages",
type: "number",
},
{
path: "Memory.Scope",
label: "Memory scope",
type: "select",
options: memoryScopes,
},
],
},
{
title: "Custom instructions",
fields: [
{
path: "CustomInstructions",
label: "Instruction lines",
type: "stringList",
description: "One instruction per line sent to the AI.",
},
],
},
{
title: "Messages",
fields: [
{
path: "Messages.ErrorResponse",
label: "Error response",
type: "text",
},
{ path: "Messages.AIError", label: "AI error", type: "text" },
{ path: "Messages.AIDisabled", label: "AI disabled", type: "text" },
{
path: "Messages.MessageTooLong",
label: "Message too long",
type: "text",
},
{
path: "Messages.SuggestTicketText",
label: "Suggest ticket text",
type: "text",
},
{
path: "Messages.SuggestTicketReaction",
label: "Suggest ticket emoji",
type: "emoji",
},
],
},
];
@@ -0,0 +1,578 @@
import type { ConfigSchema } from "./types";
const activityTypes = [
{ value: "Playing", label: "Playing" },
{ value: "Watching", label: "Watching" },
{ value: "Listening", label: "Listening" },
{ value: "Competing", label: "Competing" },
{ value: "Streaming", label: "Streaming" },
];
const suggestionModes = [
{ value: "Channel", label: "Channel" },
{ value: "Forum", label: "Forum" },
];
const ticketTypes = [
{ value: "channels", label: "Channels" },
{ value: "threads", label: "Threads" },
];
const ticketSubjects = [
{ value: "description", label: "Channel description" },
{ value: "embed", label: "Embed" },
];
const buttonStyles = [
{ value: "1", label: "Blurple" },
{ value: "2", label: "Grey" },
{ value: "3", label: "Green" },
{ value: "4", label: "Red" },
];
export const supportbotSchema: ConfigSchema = [
{
title: "General",
fields: [
{ path: "General.Name", label: "Bot name", type: "text" },
{
path: "General.Token",
label: "Bot token",
type: "secret",
description: "Masked in the dashboard. Your real token is preserved on save.",
},
{
path: "General.GuildId",
label: "Server (guild) ID",
type: "discordId",
description:
"Right-click your Discord server icon → Copy Server ID. The bot should only stay in this server.",
placeholder: "Discord server snowflake ID",
},
{
path: "General.Addons.Enabled",
label: "Addons enabled",
type: "boolean",
},
],
},
{
title: "Database",
fields: [
{
path: "Database.Driver",
label: "Database Engine",
type: "select",
options: [
{ value: "sqlite", label: "SQLite (Local File)" },
{ value: "mysql", label: "MySQL / MariaDB" },
],
description: "Select SQLite for zero-config file storage, or MySQL for external host servers.",
},
{
path: "Database.MySQL.Host",
label: "MySQL Host",
type: "text",
placeholder: "127.0.0.1 or localhost",
description: "Hostname or IP address of your MySQL database server.",
},
{
path: "Database.MySQL.Port",
label: "MySQL Port",
type: "number",
placeholder: "3306",
},
{
path: "Database.MySQL.Database",
label: "Database Name",
type: "text",
placeholder: "supportbot",
},
{
path: "Database.MySQL.User",
label: "Database User",
type: "text",
placeholder: "root",
},
{
path: "Database.MySQL.Password",
label: "Database Password",
type: "secret",
placeholder: "••••••••••••",
},
{
path: "Database.MySQL.ConnectionLimit",
label: "Connection Pool Limit",
type: "number",
placeholder: "10",
description: "Maximum simultaneous connections in the MySQL pool (default 10).",
},
{
path: "Database.SQLite.File",
label: "SQLite Database File Path",
type: "text",
placeholder: "./Data/supportbot.db",
},
],
},
{
title: "Activity",
fields: [
{ path: "Activity.Status", label: "Status text", type: "text" },
{
path: "Activity.Type",
label: "Activity type",
type: "select",
options: activityTypes,
},
{
path: "Activity.StreamingURL",
label: "Streaming URL",
type: "text",
description: "Required when type is Streaming.",
},
],
},
{
title: "Embeds",
fields: [
{ path: "Embed.Colours.General", label: "General colour", type: "color" },
{ path: "Embed.Colours.Success", label: "Success colour", type: "color" },
{ path: "Embed.Colours.Error", label: "Error colour", type: "color" },
{ path: "Embed.Colours.Warn", label: "Warn colour", type: "color" },
{ path: "Embed.Footer", label: "Footer text", type: "text" },
],
},
{
title: "Roles",
fields: [
{
path: "Roles.AutoRole.Enabled",
label: "Auto role enabled",
type: "boolean",
},
{
path: "Roles.AutoRole.Role",
label: "Auto role",
type: "discordRole",
},
{
path: "Roles.StaffMember.Staff",
label: "Staff role",
type: "discordRole",
},
{
path: "Roles.StaffMember.Admin",
label: "Admin role",
type: "discordRole",
},
{
path: "Roles.StaffMember.Moderator",
label: "Moderator role",
type: "discordRole",
},
{
path: "Roles.Mod.AllowSupportStaff",
label: "Allow support staff /mod",
type: "boolean",
},
],
},
{
title: "Welcome & leave",
fields: [
{ path: "Welcome.Enabled", label: "Welcome enabled", type: "boolean" },
{
path: "Welcome.Channel",
label: "Welcome channel",
type: "discordChannel",
},
{ path: "Leave.Enabled", label: "Leave enabled", type: "boolean" },
{
path: "Leave.Channel",
label: "Leave channel",
type: "discordChannel",
},
],
},
{
title: "Suggestions",
fields: [
{
path: "Suggestions.Mode",
label: "Mode",
type: "select",
options: suggestionModes,
},
{
path: "Suggestions.Channel",
label: "Suggestions channel",
type: "discordChannel",
},
{
path: "Suggestions.ForumChannel",
label: "Forum channel",
type: "discordChannel",
},
{
path: "Suggestions.UpvoteEmoji",
label: "Upvote emoji",
type: "emoji",
},
{
path: "Suggestions.DownvoteEmoji",
label: "Downvote emoji",
type: "emoji",
},
{ path: "Suggestions.OwnSuggestion", label: "Own suggestion votes", type: "boolean" },
{ path: "Suggestions.ShowUsers", label: "Show users", type: "boolean" },
{
path: "Suggestions.UpvoteTitle",
label: "Upvote button title",
type: "text",
},
{
path: "Suggestions.DownvoteTitle",
label: "Downvote button title",
type: "text",
},
{
path: "Suggestions.Threads.Enabled",
label: "Suggestion threads",
type: "boolean",
},
{
path: "Suggestions.Threads.Title",
label: "Thread title",
type: "text",
},
{
path: "Suggestions.Threads.Reason",
label: "Thread reason",
type: "text",
},
{
path: "Suggestions.Buttons.Upvote",
label: "Upvote button style",
type: "select",
options: buttonStyles,
},
{
path: "Suggestions.Buttons.Downvote",
label: "Downvote button style",
type: "select",
options: buttonStyles,
},
{
path: "Suggestions.Buttons.RemoveVote",
label: "Remove vote button style",
type: "select",
options: buttonStyles,
},
{
path: "Suggestions.Buttons.RemoveVote_Title",
label: "Remove vote title",
type: "text",
},
{
path: "Suggestions.Tags.Created",
label: "Tag: created",
type: "text",
},
{
path: "Suggestions.Tags.Considering",
label: "Tag: considering",
type: "text",
},
{
path: "Suggestions.Tags.Solved",
label: "Tag: solved",
type: "text",
},
{
path: "Suggestions.Tags.Denied",
label: "Tag: denied",
type: "text",
},
],
},
{
title: "Tickets",
fields: [
{
path: "Ticket.TicketType",
label: "Ticket type",
type: "select",
options: ticketTypes,
},
{
path: "Ticket.UserManagement.DMOnAdd",
label: "DM when added to ticket",
type: "boolean",
},
{
path: "Ticket.UserManagement.DMOnRemove",
label: "DM when removed from ticket",
type: "boolean",
},
{
path: "Ticket.UserManagement.DMTranscript",
label: "DM transcript on close",
type: "boolean",
},
{
path: "Ticket.TicketHome",
label: "Ticket home channel",
type: "discordChannel",
},
{
path: "Ticket.TicketChannelsCategory",
label: "Ticket category",
type: "discordCategory",
},
{
path: "Ticket.TicketChannelsCategory2",
label: "Overflow ticket category",
type: "discordCategory",
description: "Used when the first category is full.",
},
{
path: "Ticket.TicketArchiveCategory",
label: "Archive category",
type: "discordCategory",
},
{ path: "Ticket.Channel", label: "Channel prefix", type: "text" },
{
path: "Ticket.TicketSubject",
label: "Ticket subject display",
type: "select",
options: ticketSubjects,
},
{ path: "Ticket.Timeout", label: "Close timeout (minutes)", type: "number" },
{
path: "Ticket.TicketsPerUser",
label: "Tickets per user",
type: "number",
},
{ path: "Ticket.TicketReason", label: "Require reason", type: "boolean" },
{
path: "Ticket.Close.StaffOnly",
label: "Close staff only",
type: "boolean",
},
{
path: "Ticket.Invites.StaffOnly",
label: "Ticket invites staff only",
type: "boolean",
},
{
path: "Ticket.ClaimTickets.Enabled",
label: "Claim tickets",
type: "boolean",
},
{
path: "Ticket.ClaimTickets.Channel",
label: "Claim log channel",
type: "discordChannel",
},
{
path: "Ticket.ClaimTickets.ClaimAcceptTime",
label: "Claim accept time (ms)",
type: "number",
},
{
path: "Ticket.ClaimTickets.ButtonTitle",
label: "Claim button title",
type: "text",
},
{
path: "Ticket.ClaimTickets.ButtonEmoji",
label: "Claim button emoji",
type: "emoji",
},
{
path: "Ticket.ClaimTickets.Button",
label: "Claim button style",
type: "select",
options: buttonStyles,
},
{
path: "Ticket.ReviewSystem.Enabled",
label: "Review system",
type: "boolean",
},
{
path: "Ticket.ReviewSystem.Channel",
label: "Review channel",
type: "discordChannel",
},
{
path: "Ticket.ReviewSystem.UseModal",
label: "Review uses modal",
type: "boolean",
},
{
path: "Ticket.Log.TicketDataLog",
label: "Ticket data log channel",
type: "discordChannel",
},
{
path: "Ticket.Log.TickeDataTitle",
label: "Ticket data log title",
type: "text",
},
{
path: "Ticket.Log.TicketBlacklistLog",
label: "Blacklist log channel",
type: "discordChannel",
},
{
path: "Ticket.AIMode.Enabled",
label: "AI mode in tickets",
type: "boolean",
},
{
path: "Ticket.AIMode.ThreadName",
label: "AI thread name",
type: "text",
},
{
path: "Ticket.AIMode.WelcomeMessage",
label: "AI welcome message",
type: "textarea",
},
{
path: "Ticket.Questions.Enabled",
label: "Ticket questions",
type: "boolean",
},
{
path: "Ticket.Questions.List",
label: "Default questions",
type: "stringList",
},
],
},
{
title: "Ticket Departments",
description: "Configure ticket department channels, emojis, staff roles, and custom department questions.",
fields: [
{
path: "Ticket.DepartmentSystem.Enabled",
label: "Departments enabled",
type: "boolean",
},
{
path: "Ticket.DepartmentSystem.Placeholder",
label: "Department placeholder",
type: "text",
},
{
path: "Ticket.DepartmentSystem.DefaultDepartment",
label: "Default department key",
type: "text",
},
],
},
{
title: "Ticket Priorities",
description: "Configure ticket priority levels and emojis.",
fields: [
{
path: "Ticket.PrioritySystem.Enabled",
label: "Priority system",
type: "boolean",
},
{
path: "Ticket.PrioritySystem.DefaultPriority",
label: "Default priority",
type: "text",
},
{
path: "Ticket.PrioritySystem.AllowUsersToChooseOnOpen",
label: "User picks priority on open",
type: "boolean",
},
],
},
{
title: "Voice tickets",
fields: [
{ path: "VoiceTickets.Name", label: "VC name format", type: "text" },
{
path: "VoiceTickets.Category",
label: "VC category",
type: "discordCategory",
},
],
},
{
title: "Logging",
fields: [
{
path: "MessageDelete.Channel",
label: "Message delete log channel",
type: "discordChannel",
},
{
path: "MessageDelete.Colour",
label: "Message delete embed colour",
type: "color",
},
{
path: "MessageUpdate.Channel",
label: "Message update log channel",
type: "discordChannel",
},
{
path: "MessageUpdate.Colour",
label: "Message update embed colour",
type: "color",
},
{
path: "Translate.TranslateLog",
label: "Translate log channel",
type: "discordChannel",
},
],
},
{
title: "Buttons & menus",
description: "Ticket panel and control button labels and styles.",
fields: [
{ path: "Buttons.General.Delete", label: "Delete emoji", type: "emoji" },
{
path: "Buttons.General.DeleteStyle",
label: "Delete button style",
type: "select",
options: buttonStyles,
},
{
path: "Buttons.Voice.TicketDeleteText",
label: "Voice delete label",
type: "text",
},
{
path: "Buttons.Tickets.ClaimEmoji",
label: "Ticket claim emoji",
type: "emoji",
},
{
path: "Buttons.Tickets.ClaimStyle",
label: "Ticket claim style",
type: "select",
options: buttonStyles,
},
{
path: "SelectMenus.Tickets.PanelEmoji",
label: "Panel menu emoji",
type: "emoji",
},
{
path: "SelectMenus.Tickets.CloseEmoji",
label: "Close menu emoji",
type: "emoji",
},
],
},
];
@@ -0,0 +1,109 @@
import type { ConfigSchema } from "./types";
const buttonStyles = [
{ value: "1", label: "Blurple" },
{ value: "2", label: "Grey" },
{ value: "3", label: "Green" },
{ value: "4", label: "Red" },
];
const layouts = [
{ value: "1", label: "Style 1" },
{ value: "2", label: "Style 2" },
{ value: "3", label: "Style 3" },
{ value: "4", label: "Style 4 (classic embed)" },
];
export const ticketPanelSchema: ConfigSchema = [
{
title: "Panel",
fields: [{ path: "Panel", label: "Panel enabled", type: "boolean" }],
},
{
title: "Embed style",
fields: [
{ path: "Style.Title", label: "Title", type: "text" },
{ path: "Style.Description", label: "Description", type: "textarea" },
{ path: "Style.Image", label: "Image URL", type: "text" },
{ path: "Style.Color", label: "Embed colour", type: "color" },
{
path: "Style.Layout",
label: "Layout",
type: "select",
options: layouts,
},
],
},
{
title: "Create button",
fields: [
{ path: "Button.Text", label: "Button text", type: "text" },
{ path: "Button.Emoji", label: "Button emoji", type: "emoji" },
{
path: "Button.Color",
label: "Button style",
type: "select",
options: buttonStyles,
},
{ path: "Button.CustomId", label: "Custom ID", type: "text" },
],
},
{
title: "Department menu",
fields: [
{ path: "DepartmentMenu.CustomId", label: "Custom ID", type: "text" },
{ path: "DepartmentMenu.Placeholder", label: "Placeholder", type: "text" },
{
path: "DepartmentMenu.DefaultDescription",
label: "Default description",
type: "text",
},
{
path: "DepartmentMenu.DefaultEmoji",
label: "Default emoji",
type: "emoji",
},
],
},
{
title: "Panel settings",
fields: [
{ path: "Settings.Image", label: "Show image", type: "boolean" },
{ path: "Settings.TopTitle", label: "Top title", type: "boolean" },
{ path: "Settings.TopDivider", label: "Top divider", type: "boolean" },
{
path: "Settings.BottomTitle",
label: "Bottom title",
type: "boolean",
},
{
path: "Settings.BottomDivider",
label: "Bottom divider",
type: "boolean",
},
],
},
{
title: "Messages",
description: "Bot messages when using the ticket panel command.",
fields: [
{
path: "Messages.NotConfigured",
label: "Not configured",
type: "textarea",
},
{
path: "Messages.InvalidChannel",
label: "Invalid channel",
type: "textarea",
},
{ path: "Messages.PanelSent", label: "Panel sent", type: "text" },
{
path: "Messages.PanelSentWithDepartments",
label: "Panel sent (departments)",
type: "text",
},
{ path: "Messages.SendError", label: "Send error", type: "text" },
],
},
];
+32
View File
@@ -0,0 +1,32 @@
export type FieldType =
| "text"
| "textarea"
| "boolean"
| "number"
| "color"
| "emoji"
| "select"
| "discordId"
| "discordChannel"
| "discordRole"
| "discordCategory"
| "secret"
| "stringList"
| "googleFont";
export interface ConfigField {
path: string;
label: string;
type: FieldType;
description?: string;
options?: { value: string; label: string }[];
placeholder?: string;
}
export interface ConfigSection {
title: string;
description?: string;
fields: ConfigField[];
}
export type ConfigSchema = ConfigSection[];
+57
View File
@@ -0,0 +1,57 @@
export function getByPath(obj: Record<string, unknown>, path: string): unknown {
const keys = path.split(".");
let cur: unknown = obj;
for (const key of keys) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
export function setByPath(
obj: Record<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> {
const keys = path.split(".");
const clone = structuredClone(obj);
let cur: Record<string, unknown> = clone;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (cur[key] == null || typeof cur[key] !== "object") {
cur[key] = {};
}
cur = cur[key] as Record<string, unknown>;
}
cur[keys[keys.length - 1]] = value;
return clone;
}
export function flattenUpdates(
configFile: string,
obj: Record<string, unknown>,
prefix = "",
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (
val !== null &&
typeof val === "object" &&
!Array.isArray(val) &&
Object.keys(val as object).length > 0 &&
!isLeafObject(val as Record<string, unknown>)
) {
Object.assign(out, flattenUpdates(configFile, val as Record<string, unknown>, path));
} else {
out[`${configFile}:${path}`] = val;
}
}
return out;
}
function isLeafObject(o: Record<string, unknown>): boolean {
return Object.values(o).every(
(v) => v === null || typeof v !== "object" || Array.isArray(v),
);
}
@@ -0,0 +1,82 @@
import { api } from "@/api/client";
import type {
ClientAlertProvider,
DashboardAlertPayload,
DashboardAlertSeverity,
} from "./types";
const SEVERITY_ORDER: Record<DashboardAlertSeverity, number> = {
error: 0,
warning: 1,
info: 2,
};
function sortAlerts(alerts: DashboardAlertPayload[]) {
return [...alerts].sort((a, b) => {
const sa = SEVERITY_ORDER[a.severity] ?? 9;
const sb = SEVERITY_ORDER[b.severity] ?? 9;
if (sa !== sb) return sa - sb;
return a.title.localeCompare(b.title);
});
}
function dedupeAlerts(alerts: DashboardAlertPayload[]) {
const seen = new Set<string>();
return alerts.filter((alert) => {
if (seen.has(alert.id)) return false;
seen.add(alert.id);
return true;
});
}
/**
* Client-side alert registry. Server alerts come from GET /api/alerts;
* register providers here for browser-only checks (feature flags, UI hints, etc.).
*
* Example:
* dashboardAlertBuilder.register({
* id: "local-hint",
* evaluate: () => [{
* id: "local:hint",
* severity: "info",
* category: "local",
* title: "Tip",
* message: "You can customise accent colour in Settings.",
* }],
* });
*/
class DashboardAlertBuilder {
private clientProviders = new Map<string, ClientAlertProvider>();
register(provider: ClientAlertProvider) {
this.clientProviders.set(provider.id, provider);
return this;
}
unregister(id: string) {
this.clientProviders.delete(id);
}
private async evaluateClientProviders() {
const alerts: DashboardAlertPayload[] = [];
for (const provider of this.clientProviders.values()) {
const result = await provider.evaluate();
if (Array.isArray(result) && result.length) {
alerts.push(...result);
}
}
return alerts;
}
async loadAll(): Promise<DashboardAlertPayload[]> {
const [serverRes, clientAlerts] = await Promise.all([
api.getAlerts().catch(() => ({ data: [] as DashboardAlertPayload[] })),
this.evaluateClientProviders(),
]);
const serverAlerts = serverRes.data ?? [];
return sortAlerts(dedupeAlerts([...serverAlerts, ...clientAlerts]));
}
}
export const dashboardAlertBuilder = new DashboardAlertBuilder();
@@ -0,0 +1,12 @@
export { dashboardAlertBuilder } from "./builder";
export type {
ClientAlertProvider,
DashboardAlertAction,
DashboardAlertDefinition,
DashboardAlertGuildMeta,
DashboardAlertModuleMeta,
DashboardAlertPayload,
DashboardAlertRenderContext,
DashboardAlertSeverity,
} from "./types";
export { SEVERITY_STYLES } from "./types";
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import type { GuildHealthStatus, GuildSummary } from "@/api/client";
export type DashboardAlertSeverity = "error" | "warning" | "info";
export interface DashboardAlertAction {
label: string;
href: string;
}
export interface DashboardAlertGuildMeta {
status?: GuildHealthStatus;
configuredGuildId?: string | null;
configuredGuild?: GuildSummary | null;
extraGuilds?: GuildSummary[];
}
export interface DashboardAlertModuleMeta {
moduleId?: string;
status?: "todo" | "progress" | "done";
passed?: number;
total?: number;
progress?: string | null;
}
export interface DashboardAlertPayload {
id: string;
severity: DashboardAlertSeverity;
category: string;
title: string;
message: string;
action?: DashboardAlertAction;
meta?: DashboardAlertGuildMeta & DashboardAlertModuleMeta & Record<string, unknown>;
}
export interface DashboardAlertRenderContext {
canLeaveGuild: boolean;
onLeaveGuild: (guildId: string) => Promise<void>;
leavingGuildId: string | null;
actionError: string;
}
export interface ClientAlertProvider {
id: string;
evaluate: () => DashboardAlertPayload[] | Promise<DashboardAlertPayload[]>;
}
export interface DashboardAlertDefinition extends DashboardAlertPayload {
/** Optional custom body rendered below the message */
body?: ReactNode;
}
export const SEVERITY_STYLES: Record<
DashboardAlertSeverity,
{ border: string; bg: string; icon: string; title: string }
> = {
error: {
border: "border-destructive/40",
bg: "bg-destructive/10",
icon: "text-destructive",
title: "text-destructive",
},
warning: {
border: "border-amber-500/40",
bg: "bg-amber-500/10",
icon: "text-amber-500",
title: "text-amber-200",
},
info: {
border: "border-sky-500/40",
bg: "bg-sky-500/10",
icon: "text-sky-400",
title: "text-sky-100",
},
};
+87
View File
@@ -0,0 +1,87 @@
/** Discord custom emoji mention: <:name:id> or <a:name:id> */
const CUSTOM_EMOJI_RE = /^<(a?):([^:>]+):(\d{17,20})>$/;
export interface GuildEmojiOption {
id: string;
name: string;
animated: boolean;
url: string | null;
}
export interface ParsedEmoji {
kind: "unicode" | "custom" | "empty";
/** Value stored in YAML */
value: string;
name?: string;
id?: string;
animated?: boolean;
}
export function formatCustomEmoji(
name: string,
id: string,
animated = false,
): string {
const prefix = animated ? "<a" : "<";
return `${prefix}:${name}:${id}>`;
}
export function parseEmojiValue(raw: unknown): ParsedEmoji {
const value = String(raw ?? "").trim();
if (!value) {
return { kind: "empty", value: "" };
}
const match = value.match(CUSTOM_EMOJI_RE);
if (match) {
return {
kind: "custom",
value,
animated: match[1] === "a",
name: match[2],
id: match[3],
};
}
return { kind: "unicode", value };
}
export function emojiCdnUrl(id: string, animated = false): string {
return `https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "png"}?size=64`;
}
export function resolveEmojiPreview(
value: unknown,
guildEmojis: GuildEmojiOption[] = [],
): ParsedEmoji {
const parsed = parseEmojiValue(value);
if (parsed.kind === "custom" && parsed.id && !parsed.name) {
const found = guildEmojis.find((e) => e.id === parsed.id);
if (found) {
return {
kind: "custom",
value: formatCustomEmoji(found.name, found.id, found.animated),
name: found.name,
id: found.id,
animated: found.animated,
};
}
}
if (parsed.kind === "custom" && parsed.id) {
const found = guildEmojis.find((e) => e.id === parsed.id);
if (found && parsed.value !== formatCustomEmoji(found.name, found.id, found.animated)) {
return {
kind: "custom",
value: formatCustomEmoji(found.name, found.id, found.animated),
name: found.name,
id: found.id,
animated: found.animated,
};
}
}
return parsed;
}
export function isEmojiFieldKey(key: string): boolean {
return /emoji/i.test(key);
}
+10
View File
@@ -0,0 +1,10 @@
export function formatLogTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
@@ -0,0 +1,91 @@
import type { ConfigField, ConfigSchema, FieldType } from "@/lib/config-schemas";
function labelFromKey(key: string): string {
return key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function inferFieldType(key: string, value: unknown): FieldType {
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return "number";
if (Array.isArray(value)) return "stringList";
if (typeof value === "string") {
const k = key.toLowerCase();
if (/prefix/.test(k)) return "text";
if (/emoji/.test(k)) return "emoji";
if (/guild.?id|guild_id/.test(k)) return "discordId";
if (/category/.test(k)) return "discordCategory";
if (/channel|forum/.test(k)) return "discordChannel";
if (/role|staff|admin|moderator/.test(k)) return "discordRole";
if (value.length > 100) return "textarea";
return "text";
}
return "text";
}
export function collectConfigFields(
obj: Record<string, unknown>,
prefix = "",
): ConfigField[] {
const fields: ConfigField[] = [];
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (Array.isArray(val)) {
const hasObjects =
val.length > 0 &&
typeof val[0] === "object" &&
val[0] !== null &&
!Array.isArray(val[0]);
if (hasObjects) continue;
fields.push({
path,
label: labelFromKey(key),
type: "stringList",
});
continue;
}
if (
val &&
typeof val === "object" &&
Object.keys(val as object).length > 0
) {
fields.push(...collectConfigFields(val as Record<string, unknown>, path));
continue;
}
fields.push({
path,
label: labelFromKey(key),
type: inferFieldType(key, val),
});
}
return fields;
}
export function schemaFromObject(data: Record<string, unknown>): ConfigSchema {
const fields = collectConfigFields(data);
if (fields.length === 0) {
return [
{
title: "Configuration",
fields: [],
},
];
}
return [
{
title: "Settings",
description: "Values from this addon’s config file.",
fields,
},
];
}
+77
View File
@@ -0,0 +1,77 @@
/** Curated Google Fonts (keep in sync with Structures/googleFonts.js). */
export const SYSTEM_FONT_ID = "system";
export const GOOGLE_FONTS = [
{ id: "Inter", label: "Inter" },
{ id: "Roboto", label: "Roboto" },
{ id: "Open Sans", label: "Open Sans" },
{ id: "Lato", label: "Lato" },
{ id: "Montserrat", label: "Montserrat" },
{ id: "Poppins", label: "Poppins" },
{ id: "Nunito", label: "Nunito" },
{ id: "Raleway", label: "Raleway" },
{ id: "Work Sans", label: "Work Sans" },
{ id: "Source Sans 3", label: "Source Sans 3" },
{ id: "DM Sans", label: "DM Sans" },
{ id: "Manrope", label: "Manrope" },
{ id: "Outfit", label: "Outfit" },
{ id: "Rubik", label: "Rubik" },
{ id: "Ubuntu", label: "Ubuntu" },
{ id: "Fira Sans", label: "Fira Sans" },
{ id: "PT Sans", label: "PT Sans" },
{ id: "Noto Sans", label: "Noto Sans" },
{ id: "Oswald", label: "Oswald" },
{ id: "Merriweather", label: "Merriweather" },
{ id: "Playfair Display", label: "Playfair Display" },
{ id: "Libre Baskerville", label: "Libre Baskerville" },
{ id: "JetBrains Mono", label: "JetBrains Mono" },
{ id: "Fira Code", label: "Fira Code" },
] as const;
export const GOOGLE_FONT_IDS = new Set<string>(
GOOGLE_FONTS.map((f) => f.id),
);
export function googleFontsCssUrl(familyIds: string[]) {
const families = familyIds
.map((id) => {
const encoded = encodeURIComponent(id).replace(/%20/g, "+");
return `family=${encoded}:wght@400;600;700;800`;
})
.join("&");
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
}
/** Load all curated fonts in the dashboard (dropdown + preview labels). */
export function injectGoogleFontsStylesheet() {
const id = "supportbot-google-fonts";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = googleFontsCssUrl(GOOGLE_FONTS.map((f) => f.id));
document.head.appendChild(link);
}
export function normalizeGoogleFontValue(fontFamily: unknown): string {
if (!fontFamily || typeof fontFamily !== "string") return "Inter";
const trimmed = fontFamily.trim();
if (trimmed === SYSTEM_FONT_ID) return SYSTEM_FONT_ID;
if (!trimmed.includes(",")) {
return GOOGLE_FONT_IDS.has(trimmed) ? trimmed : "Inter";
}
const match = trimmed.match(/^["']?([^"',]+)["']?/);
const first = match?.[1]?.trim();
if (first && GOOGLE_FONT_IDS.has(first)) {
return first;
}
return "Inter";
}
export function fontFamilyCss(fontId: string): string {
if (fontId === SYSTEM_FONT_ID) {
return 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
}
return `"${fontId}", ui-sans-serif, system-ui, sans-serif`;
}
+55
View File
@@ -0,0 +1,55 @@
import type { ConfigField, ConfigSchema } from "@/lib/config-schemas";
import { collectConfigFields } from "@/lib/generic-config-schema";
function sectionTitleFromPath(path: string): string {
if (path.startsWith("Ticket.DepartmentSystem.")) return "Ticket Departments";
if (path.startsWith("Ticket.PrioritySystem.")) return "Ticket Priorities";
if (path.startsWith("Ticket.")) return "Tickets";
const top = path.split(".")[0] ?? "Other";
return top
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Adds any leaf paths from `data` that are not already in `schema`. */
export function mergeSchemaWithData(
schema: ConfigSchema,
data: Record<string, unknown>,
): ConfigSchema {
const known = new Set(schema.flatMap((s) => s.fields.map((f) => f.path)));
const discovered = collectConfigFields(data).filter((f) => !known.has(f.path));
if (discovered.length === 0) return schema;
const result: ConfigSchema = schema.map((sec) => ({
...sec,
fields: [...sec.fields],
}));
const bySection = new Map<string, ConfigField[]>();
for (const field of discovered) {
const title = sectionTitleFromPath(field.path);
const list = bySection.get(title) ?? [];
list.push(field);
bySection.set(title, list);
}
for (const [title, fields] of bySection.entries()) {
const existing = result.find(
(s) => s.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
existing.fields.push(...fields);
} else {
result.push({
title,
description: "These options exist in your config file but are not in the main editor sections.",
fields: fields.sort((a, b) => a.path.localeCompare(b.path)),
});
}
}
return result;
}
+27
View File
@@ -0,0 +1,27 @@
import type { ModuleStatus } from "@/components/overview/module-strip-card";
export interface ModuleStatusInfo {
status: ModuleStatus;
subtitle: string;
passed?: number;
total?: number;
}
export const STATUS_BADGE: Record<ModuleStatus, string> = {
todo: "Needs setup",
progress: "In progress",
done: "Active",
};
export function getPluginModuleMeta(
pluginId: string,
modules?: Record<string, ModuleStatusInfo>,
fallbackSubtitle = "Configure module",
): ModuleStatusInfo {
const info = modules?.[pluginId];
if (info) return info;
return {
status: "todo",
subtitle: fallbackSubtitle,
};
}
+23
View File
@@ -0,0 +1,23 @@
/** Normalize user/config colour strings to #rrggbb (lowercase). */
export function normalizeHex(value: string, fallback = "#000000"): string {
let s = String(value ?? "").trim();
if (!s) return fallback;
if (!s.startsWith("#")) s = `#${s}`;
const hex = s.slice(1);
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
return `#${hex
.split("")
.map((c) => c + c)
.join("")
.toLowerCase()}`;
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
return fallback;
}
export function isValidHex(value: string): boolean {
const s = String(value ?? "").trim();
return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(s) || /^[0-9a-fA-F]{6}$/.test(s);
}
+31
View File
@@ -0,0 +1,31 @@
import { toast } from "sonner";
import type { ApiResponse } from "@/api/client";
export async function notifyConfigSave<T>(
saveFn: () => Promise<ApiResponse<T>>,
): Promise<ApiResponse<T>> {
const saveToast = toast.loading("Saving changes…");
try {
const res = await saveFn();
toast.success("Changes saved", { id: saveToast });
if (res.botRestart) {
const restartToast = toast.loading("Restarting bot…");
if (res.botRestart.success) {
toast.success("Bot started", { id: restartToast });
} else {
toast.error(res.botRestart.error ?? "Could not start bot", {
id: restartToast,
});
}
}
return res;
} catch (err) {
toast.error(err instanceof Error ? err.message : "Save failed", {
id: saveToast,
});
throw err;
}
}
+235
View File
@@ -0,0 +1,235 @@
export const CONFIG_FILES = [
"supportbot",
"ticket-panel",
"commands",
"messages",
"supportbot-ai",
] as const;
export type ConfigFile = (typeof CONFIG_FILES)[number];
export type DashboardRole =
| "owner"
| "admin"
| "moderator"
| "editor"
| "viewer"
| "custom";
export interface ConfigFilePermission {
view: boolean;
edit: boolean;
}
export interface DashboardPermissions {
overview: boolean;
tickets: boolean;
logs: boolean;
transcripts: boolean;
rawYaml?: boolean;
settings: { view: boolean; update: boolean };
configs: Record<ConfigFile, ConfigFilePermission>;
users: { view: boolean; manage: boolean };
}
export interface DashboardUser {
id: string;
username: string;
globalName: string | null;
avatar: string;
role: string;
groupId?: string;
isOwner: boolean;
permissions: DashboardPermissions;
}
export interface StoredDashboardUser extends DashboardUser {
enabled: boolean;
yamlOwner: boolean;
createdAt: number;
updatedAt: number;
}
export interface DashboardGroup {
id: string;
name: string;
description: string;
color: string;
discordRoleId: string | null;
isSystem: boolean;
permissions: DashboardPermissions;
createdAt?: number;
updatedAt?: number;
}
export const ROLE_LABELS: Record<DashboardRole, string> = {
owner: "Owner",
admin: "Admin",
moderator: "Moderator",
editor: "Editor",
viewer: "Viewer",
custom: "Custom",
};
export const ROLE_HINTS: Record<DashboardRole, string> = {
owner: "Full access to everything",
admin: "Full access including user management",
moderator: "View dashboard, logs, transcripts, and configs",
editor: "Edit configuration files",
viewer: "View-only access",
custom: "Permissions you configure below",
};
export const CONFIG_FILE_LABELS: Record<ConfigFile, string> = {
supportbot: "Bot config",
"ticket-panel": "Ticket panel",
commands: "Commands",
messages: "Messages",
"supportbot-ai": "AI assistant",
};
export function hasPermission(
permissions: DashboardPermissions | null | undefined,
key: string,
): boolean {
if (!permissions) return false;
if (key === "overview") return permissions.overview;
if (key === "tickets") return permissions.tickets ?? permissions.overview;
if (key === "logs") return permissions.logs;
if (key === "transcripts") return permissions.transcripts;
if (key === "rawYaml" || key === "configs.raw_yaml" || key === "configs.rawYaml") {
return Boolean(permissions.rawYaml ?? (permissions.configs as unknown as Record<string, boolean>)?.rawYaml ?? false);
}
if (key === "settings.view") return permissions.settings.view;
if (key === "settings.update") return permissions.settings.update;
if (key === "users.view") return permissions.users.view;
if (key === "users.manage") return permissions.users.manage;
const viewMatch = key.match(/^configs\.([a-z0-9-]+)\.view$/);
if (viewMatch) {
const file = viewMatch[1] as ConfigFile;
return Boolean(permissions.configs[file]?.view);
}
const editMatch = key.match(/^configs\.([a-z0-9-]+)\.edit$/);
if (editMatch) {
const file = editMatch[1] as ConfigFile;
return Boolean(permissions.configs[file]?.edit);
}
return false;
}
export function canViewConfig(
permissions: DashboardPermissions | null | undefined,
file: string,
): boolean {
return hasPermission(permissions, `configs.${file}.view`);
}
export function canEditConfig(
permissions: DashboardPermissions | null | undefined,
file: string,
): boolean {
return hasPermission(permissions, `configs.${file}.edit`);
}
export function canViewAnyConfig(
permissions: DashboardPermissions | null | undefined,
): boolean {
return CONFIG_FILES.some((file) => canViewConfig(permissions, file));
}
function configMap(view: boolean, edit: boolean): Record<ConfigFile, ConfigFilePermission> {
return CONFIG_FILES.reduce(
(acc, file) => {
acc[file] = { view, edit };
return acc;
},
{} as Record<ConfigFile, ConfigFilePermission>,
);
}
export function fullPermissions(
rawYaml: boolean = true,
userManage: boolean = true,
): DashboardPermissions {
return {
overview: true,
tickets: true,
logs: true,
transcripts: true,
rawYaml,
settings: { view: true, update: true },
configs: configMap(true, true),
users: { view: true, manage: userManage },
};
}
export function defaultPermissionsForRole(
role: DashboardRole,
custom?: DashboardPermissions,
): DashboardPermissions {
switch (role) {
case "owner":
return fullPermissions(true, true);
case "admin":
return fullPermissions(false, false);
case "moderator":
return {
overview: true,
tickets: true,
logs: true,
transcripts: true,
rawYaml: false,
settings: { view: true, update: false },
configs: configMap(true, false),
users: { view: false, manage: false },
};
case "editor":
return {
overview: true,
tickets: true,
logs: true,
transcripts: false,
rawYaml: false,
settings: { view: true, update: false },
configs: configMap(true, true),
users: { view: false, manage: false },
};
case "viewer":
return {
overview: true,
tickets: true,
logs: true,
transcripts: true,
rawYaml: false,
settings: { view: true, update: false },
configs: configMap(true, false),
users: { view: false, manage: false },
};
case "custom":
return custom ?? defaultPermissionsForRole("viewer");
default:
return defaultPermissionsForRole("viewer");
}
}
export function displayDiscordName(
user: Pick<DashboardUser, "username" | "globalName">,
): string {
return user.globalName || user.username;
}
export function firstAllowedPath(
permissions: DashboardPermissions | null | undefined,
): string {
if (hasPermission(permissions, "overview")) return "/";
if (hasPermission(permissions, "logs")) return "/logs";
if (canViewAnyConfig(permissions)) return "/configs?file=supportbot";
if (hasPermission(permissions, "transcripts")) return "/transcripts";
if (hasPermission(permissions, "settings.view")) return "/settings";
if (hasPermission(permissions, "users.view")) return "/users";
return "/login";
}
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more