initial commit
This commit is contained in:
commit
316b63561a
118 changed files with 11396 additions and 0 deletions
8
.env.example
Normal file
8
.env.example
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
BOT_CLIENT_ID="<BOT USER ID>"
|
||||||
|
BOT_CLIENT_SECRET="<BOT SECRET>"
|
||||||
|
|
||||||
|
# The absolute url of the dashboard (Production)
|
||||||
|
APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# The absolute url of the api endpoint of your bot (Production)
|
||||||
|
NEXT_PUBLIC_API_ENDPOINT="http://localhost:8080"
|
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next
|
||||||
|
/out
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
/dist
|
||||||
|
/.next
|
||||||
|
/coverage
|
||||||
|
/node_modules
|
11
.prettierrc
Normal file
11
.prettierrc
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"printWidth": 250,
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": true,
|
||||||
|
"parser": "babel"
|
||||||
|
}
|
213
README.md
Normal file
213
README.md
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
# Discord Bot Dashboard Template
|
||||||
|
|
||||||
|
> Forked from [here](https://github.com/FileEditor97/discord-bot-dashboard)
|
||||||
|
|
||||||
|
Using Typescript, Next.js 13, React 18 and Chakra ui 2.0
|
||||||
|
|
||||||
|
- Support Light/Dark theme
|
||||||
|
- Multi languages support (i18n)
|
||||||
|
- Typescript support
|
||||||
|
- Nice UI & UX + Fast performance
|
||||||
|
- Flexiable and Customizable
|
||||||
|
- Detailed Documentation
|
||||||
|
|
||||||
|
**Video:** <https://youtu.be/IdMPjT5PzVk>\
|
||||||
|
**Live Demo:** <https://demo-bot.vercel.app>
|
||||||
|
|
||||||
|
- Only 'Welcome message' Feature is Supported
|
||||||
|
|
||||||
|
## Review (not the latest version)
|
||||||
|
|
||||||
|
| Light | Dark |
|
||||||
|
| :--------------------------------------: | :------------------------------------: |
|
||||||
|
| ![light-mode](./document/home-light.png) | ![dark-mode](./document/home-dark.png) |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
As a template, you need to customize a few things in order to get it work
|
||||||
|
|
||||||
|
### Before that
|
||||||
|
|
||||||
|
- Install Node.js, and a Package Manager (ex: npm or pnpm)
|
||||||
|
|
||||||
|
### Required Skills
|
||||||
|
|
||||||
|
- Basic knowledge about React.js
|
||||||
|
- Able to read javascript/typescript
|
||||||
|
|
||||||
|
### Set up
|
||||||
|
|
||||||
|
1. **Clone the repo**
|
||||||
|
\
|
||||||
|
`git clone https://github.com/fuma_nama/discord-bot-dashboard-next.git`
|
||||||
|
2. **Install dependencies**
|
||||||
|
\
|
||||||
|
We always prefer [`pnpm`](https://pnpm.io)
|
||||||
|
|
||||||
|
| NPM | PNPM |
|
||||||
|
| :-----------: | :------------: |
|
||||||
|
| `npm install` | `pnpm install` |
|
||||||
|
|
||||||
|
3. **Customize files**
|
||||||
|
\
|
||||||
|
The file structure of this project
|
||||||
|
| Path | Description |
|
||||||
|
| ------------------------------------- | ------------- |
|
||||||
|
| [src/pages/\*](./src/pages) | All the pages |
|
||||||
|
| [src/components/\*](./src/components) | Components |
|
||||||
|
| [src/api/\*](./src/api) | API utils |
|
||||||
|
| [src/config/\*](./src/api) | Common configurations |
|
||||||
|
4. **Define Features**
|
||||||
|
\
|
||||||
|
The dashboard has built-in support for configuring features
|
||||||
|
\
|
||||||
|
Users are able to enable/disable features and config the feature after enabling it
|
||||||
|
|
||||||
|
**Customize all typings in [src/config/types/custom-types.ts](./src/config/types/custom-types.ts)**
|
||||||
|
\
|
||||||
|
`CustomFeatures` is used for defining features and options, see the example for more details
|
||||||
|
|
||||||
|
**Open [src/config/features](./src/config/features.tsx)**
|
||||||
|
\
|
||||||
|
You can see how a feature is configured
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'feature-id': {
|
||||||
|
name: 'Feature name',
|
||||||
|
description: 'Description about this feature',
|
||||||
|
icon: <Icon as={BsMusicNoteBeamed} />, //give it a cool icon
|
||||||
|
useRender: (data) => {
|
||||||
|
//render the form
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `useRender` property is used to render Feature Configuration Panel \
|
||||||
|
Take a look at [here](./src/config/example/WelcomeMessageFeature.tsx) for examples
|
||||||
|
|
||||||
|
5. **Configure General Information**
|
||||||
|
\
|
||||||
|
Modify [src/config/common.tsx](./src/config/common.tsx)
|
||||||
|
- Bot name & icon
|
||||||
|
- Invite url _(example: <https://discord.com/oauth2/authorize?client_id=1234&scope=bot>)_
|
||||||
|
- Guild settings
|
||||||
|
6. **Configure Environment variables**
|
||||||
|
\
|
||||||
|
Those variables in [.env.example](./.env.example) are required
|
||||||
|
\
|
||||||
|
You can define environment variables by creating a `.env` file
|
||||||
|
7. **Setup Backend Server**
|
||||||
|
\
|
||||||
|
In order to let the dashboard connected with your discord bot, you will need a backend server
|
||||||
|
\
|
||||||
|
You can implement it in any programming languages
|
||||||
|
|
||||||
|
Read [here](#backend-development) for a guide to develop your own server
|
||||||
|
|
||||||
|
8. **Done!**
|
||||||
|
\
|
||||||
|
Start the app by `pnpm run dev` _(depends on your package manager)_
|
||||||
|
\
|
||||||
|
Then you should see the app started in port `3000`
|
||||||
|
|
||||||
|
[Localization](./document/localization.md) | [Forms](./document/form.md)
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
We are using the [API Routes](https://nextjs.org/docs/api-routes/introduction) of Next.js to handle Authorization
|
||||||
|
|
||||||
|
### Configure the Application
|
||||||
|
|
||||||
|
1. Open Discord Developer Portal
|
||||||
|
2. Create your OAuth2 application in <https://discord.com/developers/applications>
|
||||||
|
3. In `<Your Application>` -> OAuth2 -> Redirects
|
||||||
|
|
||||||
|
Add `<APP_URL>/api/auth/callback` url to the redirects
|
||||||
|
|
||||||
|
For Example: `http://localhost:3000/api/auth/callback` \
|
||||||
|
**This is required for Authorization**
|
||||||
|
|
||||||
|
### Authorization Flow
|
||||||
|
|
||||||
|
**`Login -> Discord OAuth -> API Routes -> Client`**
|
||||||
|
|
||||||
|
- Login (`/api/auth/login`)
|
||||||
|
\
|
||||||
|
- Redirects user to discord oauth url
|
||||||
|
- Open Discord OAuth url
|
||||||
|
- User authorizes the application
|
||||||
|
- Redirect back to `/api/auth/callback`
|
||||||
|
- API Routes
|
||||||
|
- Store the access token in http-only cookies
|
||||||
|
- Redirect back to home page
|
||||||
|
|
||||||
|
### Token Expiration
|
||||||
|
|
||||||
|
The Discord access token can be expired or unauthorized by the user \
|
||||||
|
We will require the user to login again after getting `401` error from the Discord API
|
||||||
|
|
||||||
|
The refresh token won't be used, but you are able to customize the Authorization Flow
|
||||||
|
|
||||||
|
## Backend Development
|
||||||
|
|
||||||
|
Check [src/api/bot.ts](./src/api/bot.ts), it defined a built-in API for fetching data
|
||||||
|
|
||||||
|
You can use `express.js` (Node.js), `Rocket` (Rust) or any libraries/languages to develop your own server
|
||||||
|
\
|
||||||
|
Usually the server runs along with your discord bot (in the same program)
|
||||||
|
\
|
||||||
|
Moreover, you can use redis instead of connecting to the bot server directly
|
||||||
|
|
||||||
|
### Official Example
|
||||||
|
|
||||||
|
[Node.js (Typescript)](https://github.com/fuma-nama/discord-dashboard-backend-next)
|
||||||
|
|
||||||
|
### Authorization Header
|
||||||
|
|
||||||
|
The client will pass their access token via the `Authorization` header
|
||||||
|
|
||||||
|
`Bearer MY_TOKEN_1212112`
|
||||||
|
|
||||||
|
### Required Routes
|
||||||
|
|
||||||
|
You may extend it for more functions
|
||||||
|
|
||||||
|
GET `/guilds/{guild}`
|
||||||
|
|
||||||
|
- Get guild info (`custom-types.ts > CustomGuildInfo`)
|
||||||
|
- **Respond 404 or `null` if bot hasn't joined the guild**
|
||||||
|
|
||||||
|
GET `/guilds/{guild}/features/{feature}`
|
||||||
|
|
||||||
|
- Get Feature options (`custom-types.ts > CustomFeatures[K]`)
|
||||||
|
- **Respond 404 if not enabled**
|
||||||
|
|
||||||
|
PATCH `/guilds/{guild}/features/{feature}`
|
||||||
|
|
||||||
|
- Update feature options
|
||||||
|
- With custom body (defined in `config/features`)
|
||||||
|
- Respond updated feature options
|
||||||
|
|
||||||
|
POST `/guilds/{guild}/features/{feature}`
|
||||||
|
|
||||||
|
- Enable a feature
|
||||||
|
|
||||||
|
DELETE `/guilds/{guild}/features/{feature}`
|
||||||
|
|
||||||
|
- Disable a feature
|
||||||
|
|
||||||
|
GET `/guilds/{guild}/roles`
|
||||||
|
|
||||||
|
- Get Roles of the guild
|
||||||
|
- Responds a list of [Role Object](https://discord.com/developers/docs/topics/permissions#role-object) _(Same as discord documentation)_
|
||||||
|
|
||||||
|
GET `/guilds/{guild}/channels`
|
||||||
|
|
||||||
|
- Get Channels of the guild
|
||||||
|
- Responds a list of [Guild Channel](https://discord.com/developers/docs/resources/channel#channel-object) _(Same as discord documentation)_
|
||||||
|
|
||||||
|
## Any issues?
|
||||||
|
|
||||||
|
Feel free to ask a question by opening a issue
|
||||||
|
|
||||||
|
**Love this project?** Give this repo a star!
|
31
document/form.md
Normal file
31
document/form.md
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# The `useForm` hook
|
||||||
|
|
||||||
|
We are using [`react-hook-form`](https://react-hook-form.com/) for forms, including feature configuration or settings page
|
||||||
|
|
||||||
|
## Built-in Components
|
||||||
|
|
||||||
|
There're some common components such as `<FilePicker />` in the [src/components/forms/\*](./src/components/forms) folder
|
||||||
|
|
||||||
|
## Controller
|
||||||
|
|
||||||
|
We add `useController` into custom components so as to provides better code quality
|
||||||
|
|
||||||
|
Therefore, You don't have to wrap the inputs into the `<Controller />` component
|
||||||
|
|
||||||
|
For example, the Color picker & Switch field can be used in this way
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ColorPickerForm
|
||||||
|
control={{
|
||||||
|
label: 'Color',
|
||||||
|
description: 'The color of message',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'color' }} //from the useForm hook
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
[Learn More](https://react-hook-form.com/api/usecontroller/)
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Take a look at [here](./src/config/example/WelcomeMessageFeature.tsx) for examples
|
BIN
document/home-dark.png
Normal file
BIN
document/home-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 244 KiB |
BIN
document/home-light.png
Normal file
BIN
document/home-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 292 KiB |
46
document/localization.md
Normal file
46
document/localization.md
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Localization
|
||||||
|
|
||||||
|
We provide a built-in localization utils for you which is light-weight and type-safe
|
||||||
|
|
||||||
|
## Add a New language
|
||||||
|
|
||||||
|
We're using the built-in [Internationalized Routing](https://nextjs.org/docs/advanced-features/i18n-routing) from Next.js in order to setup i18n
|
||||||
|
|
||||||
|
Please read their guide to more details
|
||||||
|
|
||||||
|
## Define Translations
|
||||||
|
|
||||||
|
Create the translation config (Default folder: [src/config/translations](./src/config/translations))
|
||||||
|
|
||||||
|
> test.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { provider } from './provider'; //import the provider
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export const test = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
hello: 'Hello',
|
||||||
|
},
|
||||||
|
cn: {
|
||||||
|
hello: '你好',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use it in any places
|
||||||
|
|
||||||
|
> component.tsx
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {test} from '@/config/translations/test'
|
||||||
|
|
||||||
|
export function YourComponent() {
|
||||||
|
const t = test.useTranslations();
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<p>{t.hello}</p>
|
||||||
|
<test.T text='hello'>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
```
|
BIN
document/preview-new.png
Normal file
BIN
document/preview-new.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 576 KiB |
17
next.config.js
Normal file
17
next.config.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{ source: '/auth', destination: '/auth/signin', permanent: false },
|
||||||
|
{ source: '/user', destination: '/user/home', permanent: false },
|
||||||
|
{ source: '/', destination: '/user/home', permanent: false },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
locales: ['en'],
|
||||||
|
defaultLocale: 'en',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
141
package.json
Normal file
141
package.json
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
{
|
||||||
|
"name": "jaba-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"preinstall": "npx only-allow pnpm"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@chakra-ui/anatomy": "^2.0.1",
|
||||||
|
"@chakra-ui/form-control": "^2.0.0",
|
||||||
|
"@chakra-ui/icon": "^3.0.0",
|
||||||
|
"@chakra-ui/layout": "^2.0.0",
|
||||||
|
"@chakra-ui/menu": "^2.1.8",
|
||||||
|
"@chakra-ui/react": "^2.4.0",
|
||||||
|
"@chakra-ui/spinner": "^2.0.0",
|
||||||
|
"@chakra-ui/styled-system": "^2.0.0",
|
||||||
|
"@chakra-ui/system": "^2.0.0",
|
||||||
|
"@chakra-ui/theme-tools": "^2.0.0",
|
||||||
|
"@emotion/react": "^11.8.1",
|
||||||
|
"@emotion/styled": "^11.3.0",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@tanstack/react-query": "^5.50.1",
|
||||||
|
"apexcharts": "^3.36.3",
|
||||||
|
"chakra-react-select": "^4.4.2",
|
||||||
|
"cookies-next": "^2.1.1",
|
||||||
|
"deepmerge-ts": "^7.0.3",
|
||||||
|
"framer-motion": "^11.0.0",
|
||||||
|
"next": "^14.2.4",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-apexcharts": "^1.4.0",
|
||||||
|
"react-calendar": "^5.0.0",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"react-hook-form": "^7.43.2",
|
||||||
|
"react-icons": "^5.2.1",
|
||||||
|
"zod": "^3.20.6",
|
||||||
|
"zustand": "^4.4.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.21.0",
|
||||||
|
"@types/node": "20.14.10",
|
||||||
|
"@types/react": "^18.0.0",
|
||||||
|
"@types/react-dom": "^18.0.0",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-config-next": "^14.2.4",
|
||||||
|
"eslint-config-prettier": "9.1.0",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"env": {
|
||||||
|
"commonjs": true,
|
||||||
|
"es6": true,
|
||||||
|
"es2020": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"Atomics": "readonly",
|
||||||
|
"SharedArrayBuffer": "readonly"
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2020
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"arrow-spacing": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"before": true,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"comma-dangle": [
|
||||||
|
"error",
|
||||||
|
"always-multiline"
|
||||||
|
],
|
||||||
|
"comma-spacing": "error",
|
||||||
|
"comma-style": "error",
|
||||||
|
"dot-location": [
|
||||||
|
"error",
|
||||||
|
"property"
|
||||||
|
],
|
||||||
|
"handle-callback-err": "off",
|
||||||
|
"indent": [
|
||||||
|
"error",
|
||||||
|
"tab",
|
||||||
|
{
|
||||||
|
"SwitchCase": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"keyword-spacing": "error",
|
||||||
|
"max-nested-callbacks": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"max": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max-statements-per-line": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"max": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-console": "off",
|
||||||
|
"no-multi-spaces": "error",
|
||||||
|
"no-multiple-empty-lines": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"max": 2,
|
||||||
|
"maxEOF": 1,
|
||||||
|
"maxBOF": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-trailing-spaces": [
|
||||||
|
"error"
|
||||||
|
],
|
||||||
|
"no-var": "error",
|
||||||
|
"object-curly-spacing": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"prefer-const": "error",
|
||||||
|
"quotes": [
|
||||||
|
"error",
|
||||||
|
"double"
|
||||||
|
],
|
||||||
|
"semi": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"space-in-parens": "error",
|
||||||
|
"space-infix-ops": "error",
|
||||||
|
"space-unary-ops": "error",
|
||||||
|
"yoda": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5516
pnpm-lock.yaml
Normal file
5516
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
BIN
public/Banner1.png
Normal file
BIN
public/Banner1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 604 KiB |
15
public/manifest.json
Normal file
15
public/manifest.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
|
Sitemap: https://simmmple.com
|
141
src/api/bot.ts
Normal file
141
src/api/bot.ts
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import { CustomFeatures, CustomGuildInfo } from '@/config/types/custom-types';
|
||||||
|
import { AccessToken } from '@/utils/auth/server';
|
||||||
|
import { callDefault, callReturn } from '@/utils/fetch/core';
|
||||||
|
import { botRequest } from '@/utils/fetch/requests';
|
||||||
|
import { ChannelTypes } from './discord';
|
||||||
|
|
||||||
|
export type Role = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: number;
|
||||||
|
position: number;
|
||||||
|
icon?: {
|
||||||
|
iconUrl?: string;
|
||||||
|
emoji?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuildChannel = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ChannelTypes;
|
||||||
|
/**
|
||||||
|
* parent category of the channel
|
||||||
|
*/
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom guild info on from backend
|
||||||
|
*
|
||||||
|
* @param guild Guild ID
|
||||||
|
* @return Guild info, or null if bot hasn't joined the guild
|
||||||
|
*/
|
||||||
|
export async function fetchGuildInfo(
|
||||||
|
session: AccessToken,
|
||||||
|
guild: string
|
||||||
|
): Promise<CustomGuildInfo | null> {
|
||||||
|
return await callReturn<CustomGuildInfo | null>(
|
||||||
|
`/guilds/${guild}`,
|
||||||
|
botRequest(session, {
|
||||||
|
request: {
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
allowed: {
|
||||||
|
404: () => null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableFeature(session: AccessToken, guild: string, feature: string) {
|
||||||
|
return await callDefault(
|
||||||
|
`/guilds/${guild}/features/${feature}`,
|
||||||
|
botRequest(session, {
|
||||||
|
request: {
|
||||||
|
method: 'POST',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableFeature(session: AccessToken, guild: string, feature: string) {
|
||||||
|
return await callDefault(
|
||||||
|
`/guilds/${guild}/features/${feature}`,
|
||||||
|
botRequest(session, {
|
||||||
|
request: {
|
||||||
|
method: 'DELETE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeature<K extends keyof CustomFeatures>(
|
||||||
|
session: AccessToken,
|
||||||
|
guild: string,
|
||||||
|
feature: K
|
||||||
|
): Promise<CustomFeatures[K]> {
|
||||||
|
return await callReturn<CustomFeatures[K]>(
|
||||||
|
`/guilds/${guild}/features/${feature}`,
|
||||||
|
botRequest(session, {
|
||||||
|
request: {
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFeature<K extends keyof CustomFeatures>(
|
||||||
|
session: AccessToken,
|
||||||
|
guild: string,
|
||||||
|
feature: K,
|
||||||
|
options: FormData | string
|
||||||
|
): Promise<CustomFeatures[K]> {
|
||||||
|
const isForm = options instanceof FormData;
|
||||||
|
|
||||||
|
return await callReturn<CustomFeatures[K]>(
|
||||||
|
`/guilds/${guild}/features/${feature}`,
|
||||||
|
botRequest(session, {
|
||||||
|
request: {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: isForm
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: options,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for custom forms
|
||||||
|
*
|
||||||
|
* The dashboard itself doesn't use it
|
||||||
|
* @returns Guild roles
|
||||||
|
*/
|
||||||
|
export async function fetchGuildRoles(session: AccessToken, guild: string) {
|
||||||
|
return await callReturn<Role[]>(
|
||||||
|
`/guilds/${guild}/roles`,
|
||||||
|
botRequest(session, {
|
||||||
|
request: {
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns Guild channels
|
||||||
|
*/
|
||||||
|
export async function fetchGuildChannels(session: AccessToken, guild: string) {
|
||||||
|
return await callReturn<GuildChannel[]>(
|
||||||
|
`/guilds/${guild}/channels`,
|
||||||
|
botRequest(session, {
|
||||||
|
request: {
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
129
src/api/discord.ts
Normal file
129
src/api/discord.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import { logout } from '@/utils/auth/hooks';
|
||||||
|
import { callReturn } from '@/utils/fetch/core';
|
||||||
|
import { discordRequest } from '@/utils/fetch/requests';
|
||||||
|
|
||||||
|
export type UserInfo = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
discriminator: string;
|
||||||
|
avatar: string;
|
||||||
|
mfa_enabled?: boolean;
|
||||||
|
banner?: string;
|
||||||
|
accent_color?: number;
|
||||||
|
locale?: string;
|
||||||
|
flags?: number;
|
||||||
|
premium_type?: number;
|
||||||
|
public_flags?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Guild = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
permissions: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IconHash = string;
|
||||||
|
|
||||||
|
export enum PermissionFlags {
|
||||||
|
CREATE_INSTANT_INVITE = 1 << 0,
|
||||||
|
KICK_MEMBERS = 1 << 1,
|
||||||
|
BAN_MEMBERS = 1 << 2,
|
||||||
|
ADMINISTRATOR = 1 << 3,
|
||||||
|
MANAGE_CHANNELS = 1 << 4,
|
||||||
|
MANAGE_GUILD = 1 << 5,
|
||||||
|
ADD_REACTIONS = 1 << 6,
|
||||||
|
VIEW_AUDIT_LOG = 1 << 7,
|
||||||
|
PRIORITY_SPEAKER = 1 << 8,
|
||||||
|
STREAM = 1 << 9,
|
||||||
|
VIEW_CHANNEL = 1 << 10,
|
||||||
|
SEND_MESSAGES = 1 << 11,
|
||||||
|
SEND_TTS_MESSAGES = 1 << 12,
|
||||||
|
MANAGE_MESSAGES = 1 << 13,
|
||||||
|
EMBED_LINKS = 1 << 14,
|
||||||
|
ATTACH_FILES = 1 << 15,
|
||||||
|
READ_MESSAGE_HISTORY = 1 << 16,
|
||||||
|
MENTION_EVERYONE = 1 << 17,
|
||||||
|
USE_EXTERNAL_EMOJIS = 1 << 18,
|
||||||
|
VIEW_GUILD_INSIGHTS = 1 << 19,
|
||||||
|
CONNECT = 1 << 20,
|
||||||
|
SPEAK = 1 << 21,
|
||||||
|
MUTE_MEMBERS = 1 << 22,
|
||||||
|
DEAFEN_MEMBERS = 1 << 23,
|
||||||
|
MOVE_MEMBERS = 1 << 24,
|
||||||
|
USE_VAD = 1 << 25,
|
||||||
|
CHANGE_NICKNAME = 1 << 26,
|
||||||
|
MANAGE_NICKNAMES = 1 << 27,
|
||||||
|
MANAGE_ROLES = 1 << 28,
|
||||||
|
MANAGE_WEBHOOKS = 1 << 29,
|
||||||
|
MANAGE_EMOJIS_AND_STICKERS = 1 << 30,
|
||||||
|
USE_APPLICATION_COMMANDS = 1 << 31,
|
||||||
|
REQUEST_TO_SPEAK = 1 << 32,
|
||||||
|
MANAGE_EVENTS = 1 << 33,
|
||||||
|
MANAGE_THREADS = 1 << 34,
|
||||||
|
CREATE_PUBLIC_THREADS = 1 << 35,
|
||||||
|
CREATE_PRIVATE_THREADS = 1 << 36,
|
||||||
|
USE_EXTERNAL_STICKERS = 1 << 37,
|
||||||
|
SEND_MESSAGES_IN_THREADS = 1 << 38,
|
||||||
|
USE_EMBEDDED_ACTIVITIES = 1 << 39,
|
||||||
|
MODERATE_MEMBERS = 1 << 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChannelTypes {
|
||||||
|
GUILD_TEXT = 0,
|
||||||
|
DM = 1,
|
||||||
|
GUILD_VOICE = 2,
|
||||||
|
GROUP_DM = 3,
|
||||||
|
GUILD_CATEGORY = 4,
|
||||||
|
GUILD_ANNOUNCEMENT = 5,
|
||||||
|
ANNOUNCEMENT_THREAD = 10,
|
||||||
|
PUBLIC_THREAD = 11,
|
||||||
|
PRIVATE_THREAD = 12,
|
||||||
|
GUILD_STAGE_VOICE = 13,
|
||||||
|
GUILD_DIRECTORY = 14,
|
||||||
|
GUILD_FORUM = 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserInfo(accessToken: string) {
|
||||||
|
return await callReturn<UserInfo>(
|
||||||
|
`/users/@me`,
|
||||||
|
discordRequest(accessToken, {
|
||||||
|
request: {
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
allowed: {
|
||||||
|
401: async () => {
|
||||||
|
await logout();
|
||||||
|
|
||||||
|
throw new Error('Not logged in');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuilds(accessToken: string) {
|
||||||
|
return await callReturn<Guild[]>(
|
||||||
|
`/users/@me/guilds`,
|
||||||
|
discordRequest(accessToken, { request: { method: 'GET' } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuild(accessToken: string, id: string) {
|
||||||
|
return await callReturn<Guild>(
|
||||||
|
`/guilds/${id}`,
|
||||||
|
discordRequest(accessToken, { request: { method: 'GET' } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iconUrl(guild: Guild) {
|
||||||
|
return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avatarUrl(user: UserInfo) {
|
||||||
|
return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}?size=512`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bannerUrl(id: string, banner: string): string {
|
||||||
|
return `https://cdn.discordapp.com/banners/${id}/${banner}?size=1024`;
|
||||||
|
}
|
175
src/api/hooks.ts
Normal file
175
src/api/hooks.ts
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
import { CustomFeatures, CustomGuildInfo } from '../config/types';
|
||||||
|
import { QueryClient, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { UserInfo, getGuild, getGuilds, fetchUserInfo } from '@/api/discord';
|
||||||
|
import {
|
||||||
|
disableFeature,
|
||||||
|
enableFeature,
|
||||||
|
fetchGuildChannels,
|
||||||
|
fetchGuildInfo,
|
||||||
|
fetchGuildRoles,
|
||||||
|
getFeature,
|
||||||
|
updateFeature,
|
||||||
|
} from '@/api/bot';
|
||||||
|
import { GuildInfo } from '@/config/types';
|
||||||
|
import { useAccessToken, useSession } from '@/utils/auth/hooks';
|
||||||
|
|
||||||
|
export const client = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Keys = {
|
||||||
|
login: ['login'],
|
||||||
|
guild_info: (guild: string) => ['guild_info', guild],
|
||||||
|
features: (guild: string, feature: string) => ['feature', guild, feature],
|
||||||
|
guildRoles: (guild: string) => ['gulid_roles', guild],
|
||||||
|
guildChannels: (guild: string) => ['gulid_channel', guild],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Mutations = {
|
||||||
|
updateFeature: (guild: string, id: string) => ['feature', guild, id],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGuild(id: string) {
|
||||||
|
const accessToken = useAccessToken();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['guild', id],
|
||||||
|
queryFn: () => getGuild(accessToken as string, id),
|
||||||
|
enabled: accessToken != null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuilds() {
|
||||||
|
const accessToken = useAccessToken();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['user_guilds'],
|
||||||
|
queryFn: () => getGuilds(accessToken as string),
|
||||||
|
enabled: accessToken != null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelfUserQuery() {
|
||||||
|
const accessToken = useAccessToken();
|
||||||
|
|
||||||
|
return useQuery<UserInfo>({
|
||||||
|
queryKey: ['users', 'me'],
|
||||||
|
queryFn: () => fetchUserInfo(accessToken!!),
|
||||||
|
enabled: accessToken != null,
|
||||||
|
staleTime: Infinity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuildInfoQuery(guild: string) {
|
||||||
|
const { status, session } = useSession();
|
||||||
|
|
||||||
|
return useQuery<CustomGuildInfo | null>({
|
||||||
|
queryKey: Keys.guild_info(guild),
|
||||||
|
queryFn: () => fetchGuildInfo(session!!, guild),
|
||||||
|
enabled: status === 'authenticated',
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeatureQuery<K extends keyof CustomFeatures>(guild: string, feature: K) {
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const { status, session } = useSession();
|
||||||
|
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: Keys.features(guild, feature),
|
||||||
|
queryFn: () => getFeature(session!!, guild, feature)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnableFeatureOptions = { guild: string; feature: string; enabled: boolean };
|
||||||
|
export function useEnableFeatureMutation() {
|
||||||
|
const { session } = useSession();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ enabled, guild, feature }: EnableFeatureOptions) => {
|
||||||
|
if (enabled) return enableFeature(session!!, guild, feature);
|
||||||
|
return disableFeature(session!!, guild, feature);
|
||||||
|
},
|
||||||
|
async onSuccess(_, { guild, feature, enabled }) {
|
||||||
|
await client.invalidateQueries({queryKey: Keys.features(guild, feature)});
|
||||||
|
client.setQueryData<GuildInfo | null>(Keys.guild_info(guild), (prev) => {
|
||||||
|
if (prev == null) return null;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
enabledFeatures: prev.enabledFeatures.includes(feature)
|
||||||
|
? prev.enabledFeatures
|
||||||
|
: [...prev.enabledFeatures, feature],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
enabledFeatures: prev.enabledFeatures.filter((f) => f !== feature),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateFeatureOptions = {
|
||||||
|
guild: string;
|
||||||
|
feature: keyof CustomFeatures;
|
||||||
|
options: FormData | string;
|
||||||
|
};
|
||||||
|
export function useUpdateFeatureMutation() {
|
||||||
|
const { session } = useSession();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (options: UpdateFeatureOptions) =>
|
||||||
|
updateFeature(session!!, options.guild, options.feature, options.options),
|
||||||
|
onSuccess(updated, options) {
|
||||||
|
const key = Keys.features(options.guild, options.feature);
|
||||||
|
|
||||||
|
return client.setQueryData(key, updated);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuildRolesQuery(guild: string) {
|
||||||
|
const { session } = useSession();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: Keys.guildRoles(guild),
|
||||||
|
queryFn: () => fetchGuildRoles(session!!, guild)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuildChannelsQuery(guild: string) {
|
||||||
|
const { session } = useSession();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: Keys.guildChannels(guild),
|
||||||
|
queryFn: () => fetchGuildChannels(session!!, guild)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelfUser(): UserInfo {
|
||||||
|
return useSelfUserQuery().data!!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuildPreview(guild: string) {
|
||||||
|
const query = useGuilds();
|
||||||
|
|
||||||
|
return {
|
||||||
|
guild: query.data?.find((g) => g.id === guild),
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
46
src/components/GuildBanner.tsx
Normal file
46
src/components/GuildBanner.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { FiSettings as SettingsIcon } from 'react-icons/fi';
|
||||||
|
import { Flex, Heading, Text } from '@chakra-ui/layout';
|
||||||
|
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||||
|
import { guild as view } from '@/config/translations/guild';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function Banner() {
|
||||||
|
const { guild } = useRouter().query as { guild: string };
|
||||||
|
const t = view.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
px={{ base: 5, lg: 8 }}
|
||||||
|
py={{ base: 5, lg: 7 }}
|
||||||
|
rounded="2xl"
|
||||||
|
bgColor="Brand"
|
||||||
|
bgImg={{ '3sm': '/Banner1.png' }}
|
||||||
|
bgSize="cover"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Heading color="white" fontSize={{ base: '2xl' }} fontWeight="bold">
|
||||||
|
{t.banner.title}
|
||||||
|
</Heading>
|
||||||
|
<Text color="whiteAlpha.800">{t.banner.description}</Text>
|
||||||
|
<ButtonGroup mt={3}>
|
||||||
|
<Button
|
||||||
|
leftIcon={<SettingsIcon />}
|
||||||
|
color="white"
|
||||||
|
bg="whiteAlpha.200"
|
||||||
|
_hover={{
|
||||||
|
bg: 'whiteAlpha.300',
|
||||||
|
}}
|
||||||
|
_active={{
|
||||||
|
bg: 'whiteAlpha.400',
|
||||||
|
}}
|
||||||
|
as={Link}
|
||||||
|
href={`/guilds/${guild}/settings`}
|
||||||
|
>
|
||||||
|
{t.bn.settings}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
27
src/components/SidebarTrigger.tsx
Normal file
27
src/components/SidebarTrigger.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { Flex, Icon } from '@chakra-ui/react';
|
||||||
|
import { IoMenuOutline } from 'react-icons/io5';
|
||||||
|
import { usePageStore } from '@/stores';
|
||||||
|
import { sidebarBreakpoint } from '@/theme/breakpoints';
|
||||||
|
|
||||||
|
export function SidebarTrigger() {
|
||||||
|
const setOpen = usePageStore((s) => s.setSidebarIsOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex display={{ base: 'flex', [sidebarBreakpoint]: 'none' }} alignItems="center">
|
||||||
|
<Flex w="max-content" h="max-content" onClick={() => setOpen(true)}>
|
||||||
|
<Icon
|
||||||
|
as={IoMenuOutline}
|
||||||
|
color="gray.400"
|
||||||
|
_dark={{
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
my="auto"
|
||||||
|
w="20px"
|
||||||
|
h="20px"
|
||||||
|
me="10px"
|
||||||
|
_hover={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
31
src/components/ThemeSwitch.tsx
Normal file
31
src/components/ThemeSwitch.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { Button, Icon, useColorMode } from '@chakra-ui/react';
|
||||||
|
import { IoMdMoon, IoMdSunny } from 'react-icons/io';
|
||||||
|
|
||||||
|
export function ThemeSwitch({ secondary }: { secondary?: boolean }) {
|
||||||
|
const { colorMode, toggleColorMode } = useColorMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="no-hover"
|
||||||
|
bg="transparent"
|
||||||
|
p="0px"
|
||||||
|
minW="unset"
|
||||||
|
minH="unset"
|
||||||
|
h="18px"
|
||||||
|
w="max-content"
|
||||||
|
onClick={toggleColorMode}
|
||||||
|
aria-label="Toggle color mode"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
me="10px"
|
||||||
|
h="18px"
|
||||||
|
w="18px"
|
||||||
|
color={secondary ? 'gray.400' : 'TextPrimary'}
|
||||||
|
_dark={{
|
||||||
|
color: 'TextPrimary',
|
||||||
|
}}
|
||||||
|
as={colorMode === 'light' ? IoMdMoon : IoMdSunny}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
78
src/components/chart/StyledChart.tsx
Normal file
78
src/components/chart/StyledChart.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { useColorModeValue, useToken } from '@chakra-ui/react';
|
||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import type { Props as ChartProps } from 'react-apexcharts';
|
||||||
|
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
export function StyledChart(props: ChartProps) {
|
||||||
|
const theme = useColorModeValue('light', 'dark');
|
||||||
|
const [textColorPrimary, textColorSecondary] = useToken('colors', [
|
||||||
|
'TextPrimary',
|
||||||
|
'TextSecondary',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const options: ApexCharts.ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
toolbar: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
dropShadow: {
|
||||||
|
enabled: true,
|
||||||
|
top: 13,
|
||||||
|
left: 0,
|
||||||
|
blur: 10,
|
||||||
|
opacity: 0.1,
|
||||||
|
color: '#4318FF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
fillSeriesColor: false,
|
||||||
|
theme: theme,
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 0,
|
||||||
|
colors: textColorPrimary,
|
||||||
|
strokeColors: '#7551FF',
|
||||||
|
strokeWidth: 3,
|
||||||
|
strokeOpacity: 0.9,
|
||||||
|
strokeDashArray: 0,
|
||||||
|
fillOpacity: 1,
|
||||||
|
discrete: [],
|
||||||
|
shape: 'circle',
|
||||||
|
radius: 2,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
showNullDataPoints: true,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
colors: textColorSecondary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: textColorSecondary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: textColorSecondary,
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Chart {...props} options={deepmerge(options, props.options)} />;
|
||||||
|
}
|
67
src/components/feature/FeatureItem.tsx
Normal file
67
src/components/feature/FeatureItem.tsx
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import { Box, Center, Flex, Text } from '@chakra-ui/layout';
|
||||||
|
import { Button, ButtonGroup, Card, CardBody, CardFooter } from '@chakra-ui/react';
|
||||||
|
import { IdFeature } from '@/utils/common';
|
||||||
|
import { IoOpen, IoOptions } from 'react-icons/io5';
|
||||||
|
import { useEnableFeatureMutation } from '@/api/hooks';
|
||||||
|
import { guild as view } from '@/config/translations/guild';
|
||||||
|
import Router from 'next/router';
|
||||||
|
|
||||||
|
export function FeatureItem({
|
||||||
|
guild,
|
||||||
|
feature,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
guild: string;
|
||||||
|
feature: IdFeature;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
const t = view.useTranslations();
|
||||||
|
const mutation = useEnableFeatureMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="primary">
|
||||||
|
<CardBody as={Flex} direction="row" gap={3}>
|
||||||
|
<Center
|
||||||
|
bg={enabled ? 'Brand' : 'brandAlpha.100'}
|
||||||
|
color={enabled ? 'white' : 'brand.500'}
|
||||||
|
rounded="xl"
|
||||||
|
w="50px"
|
||||||
|
h="50px"
|
||||||
|
fontSize="3xl"
|
||||||
|
_dark={{
|
||||||
|
color: enabled ? 'white' : 'brand.200',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feature.icon}
|
||||||
|
</Center>
|
||||||
|
<Box flex={1}>
|
||||||
|
<Text fontSize={{ base: '16px', md: 'lg' }} fontWeight="600">
|
||||||
|
{feature.name}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize={{ base: 'sm', md: 'md' }} color="TextSecondary">
|
||||||
|
{feature.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter as={ButtonGroup} mt={3}>
|
||||||
|
<Button
|
||||||
|
size={{ base: 'sm', md: 'md' }}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
{...(enabled
|
||||||
|
? {
|
||||||
|
variant: 'action',
|
||||||
|
rounded: '2xl',
|
||||||
|
leftIcon: <IoOptions />,
|
||||||
|
onClick: () => Router.push(`/guilds/${guild}/features/${feature.id}`),
|
||||||
|
children: t.bn['config feature'],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
leftIcon: <IoOpen />,
|
||||||
|
onClick: () => mutation.mutate({ enabled: true, guild, feature: feature.id }),
|
||||||
|
children: t.bn['enable feature'],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
112
src/components/feature/UpdateFeaturePanel.tsx
Normal file
112
src/components/feature/UpdateFeaturePanel.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { RiErrorWarningFill as WarningIcon } from 'react-icons/ri';
|
||||||
|
import { Box, Flex, Heading, Spacer, Text } from '@chakra-ui/layout';
|
||||||
|
import { ButtonGroup, Button, Icon } from '@chakra-ui/react';
|
||||||
|
import { SlideFade } from '@chakra-ui/react';
|
||||||
|
import { FeatureConfig, UseFormRenderResult, CustomFeatures } from '@/config/types';
|
||||||
|
import { IoSave } from 'react-icons/io5';
|
||||||
|
import { useEnableFeatureMutation, useUpdateFeatureMutation } from '@/api/hooks';
|
||||||
|
import { Params } from '@/pages/guilds/[guild]/features/[feature]';
|
||||||
|
import { feature as view } from '@/config/translations/feature';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
export function UpdateFeaturePanel({
|
||||||
|
feature,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
feature: CustomFeatures[keyof CustomFeatures];
|
||||||
|
config: FeatureConfig<keyof CustomFeatures>;
|
||||||
|
}) {
|
||||||
|
const { guild, feature: featureId } = useRouter().query as Params;
|
||||||
|
const mutation = useUpdateFeatureMutation();
|
||||||
|
const enableMutation = useEnableFeatureMutation();
|
||||||
|
const result = config.useRender(feature, (data) => {
|
||||||
|
return mutation.mutateAsync({
|
||||||
|
guild,
|
||||||
|
feature: featureId,
|
||||||
|
options: data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDisable = () => {
|
||||||
|
enableMutation.mutate({ enabled: false, guild, feature: featureId });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex as="form" direction="column" gap={5} w="full" h="full">
|
||||||
|
<Flex direction={{ base: 'column', md: 'row' }} mx={{ '3sm': 5 }} justify="space-between">
|
||||||
|
<Box>
|
||||||
|
<Heading fontSize="2xl" fontWeight="600">
|
||||||
|
{config.name}
|
||||||
|
</Heading>
|
||||||
|
<Text color="TextSecondary">{config.description}</Text>
|
||||||
|
</Box>
|
||||||
|
<ButtonGroup mt={3}>
|
||||||
|
<Button variant="danger" isLoading={enableMutation.isPending} onClick={onDisable}>
|
||||||
|
<view.T text={(e) => e.bn.disable} />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{result.component}
|
||||||
|
<Savebar isLoading={mutation.isPending} result={result} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Savebar({
|
||||||
|
result: { canSave, onSubmit, reset },
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
result: UseFormRenderResult;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const t = view.useTranslations();
|
||||||
|
const breakpoint = '3sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
as={SlideFade}
|
||||||
|
in={canSave}
|
||||||
|
bg="CardBackground"
|
||||||
|
rounded="3xl"
|
||||||
|
zIndex="sticky"
|
||||||
|
pos="sticky"
|
||||||
|
bottom={{ base: 2, [breakpoint]: '10px' }}
|
||||||
|
w="full"
|
||||||
|
p={{ base: 1, [breakpoint]: '15px' }}
|
||||||
|
shadow="normal"
|
||||||
|
alignItems="center"
|
||||||
|
flexDirection={{ base: 'column', [breakpoint]: 'row' }}
|
||||||
|
gap={{ base: 1, [breakpoint]: 2 }}
|
||||||
|
mt="auto"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
display={{ base: 'none', [breakpoint]: 'block' }}
|
||||||
|
as={WarningIcon}
|
||||||
|
_light={{ color: 'orange.400' }}
|
||||||
|
_dark={{ color: 'orange.300' }}
|
||||||
|
w="30px"
|
||||||
|
h="30px"
|
||||||
|
/>
|
||||||
|
<Text fontSize={{ base: 'md', [breakpoint]: 'lg' }} fontWeight="600">
|
||||||
|
{t.unsaved}
|
||||||
|
</Text>
|
||||||
|
<Spacer />
|
||||||
|
<ButtonGroup isDisabled={isLoading} size={{ base: 'sm', [breakpoint]: 'md' }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="action"
|
||||||
|
rounded="full"
|
||||||
|
leftIcon={<IoSave />}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{t.bn.save}
|
||||||
|
</Button>
|
||||||
|
<Button rounded="full" onClick={reset}>
|
||||||
|
{t.bn.discard}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
121
src/components/forms/ChannelSelect.tsx
Normal file
121
src/components/forms/ChannelSelect.tsx
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import { BsChatLeftText as ChatIcon } from 'react-icons/bs';
|
||||||
|
import { GuildChannel } from '@/api/bot';
|
||||||
|
import { ChannelTypes } from '@/api/discord';
|
||||||
|
import { Option, SelectField } from '@/components/forms/SelectField';
|
||||||
|
import { forwardRef, useMemo } from 'react';
|
||||||
|
import { MdRecordVoiceOver } from 'react-icons/md';
|
||||||
|
import { useGuildChannelsQuery } from '@/api/hooks';
|
||||||
|
import { Icon } from '@chakra-ui/react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { SelectInstance, Props as SelectProps } from 'chakra-react-select';
|
||||||
|
import { Override } from '@/utils/types';
|
||||||
|
import { ControlledInput } from './types';
|
||||||
|
import { FormCard } from './Form';
|
||||||
|
import { useController } from 'react-hook-form';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an options
|
||||||
|
*/
|
||||||
|
const render = (channel: GuildChannel): Option => {
|
||||||
|
const icon = () => {
|
||||||
|
switch (channel.type) {
|
||||||
|
case ChannelTypes.GUILD_STAGE_VOICE:
|
||||||
|
case ChannelTypes.GUILD_VOICE: {
|
||||||
|
return <Icon as={MdRecordVoiceOver} />;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return <ChatIcon />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: channel.name,
|
||||||
|
value: channel.id,
|
||||||
|
icon: icon(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapOptions(channels: GuildChannel[]) {
|
||||||
|
//channels in category
|
||||||
|
const categories = new Map<string, GuildChannel[]>();
|
||||||
|
//channels with no parent category
|
||||||
|
const roots: GuildChannel[] = [];
|
||||||
|
|
||||||
|
//group channels
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (channel.category == null) roots.push(channel);
|
||||||
|
else {
|
||||||
|
const category = categories.get(channel.category);
|
||||||
|
|
||||||
|
if (category == null) {
|
||||||
|
categories.set(channel.category, [channel]);
|
||||||
|
} else {
|
||||||
|
category.push(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//map channels into select menu options
|
||||||
|
return roots.map((channel) => {
|
||||||
|
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||||
|
return {
|
||||||
|
...render(channel),
|
||||||
|
options: categories.get(channel.id)?.map(render) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(channel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = Override<
|
||||||
|
SelectProps<Option, false>,
|
||||||
|
{
|
||||||
|
value?: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ChannelSelect = forwardRef<SelectInstance<Option, false>, Props>(
|
||||||
|
({ value, onChange, ...rest }, ref) => {
|
||||||
|
const guild = useRouter().query.guild as string;
|
||||||
|
const channelsQuery = useGuildChannelsQuery(guild);
|
||||||
|
const isLoading = channelsQuery.isLoading;
|
||||||
|
|
||||||
|
const selected = value != null ? channelsQuery.data?.find((c) => c.id === value) : null;
|
||||||
|
const options = useMemo(
|
||||||
|
() => (channelsQuery.data != null ? mapOptions(channelsQuery.data) : []),
|
||||||
|
[channelsQuery.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectField<Option>
|
||||||
|
isDisabled={isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
placeholder={<common.T text="select channel" />}
|
||||||
|
value={selected != null ? render(selected) : null}
|
||||||
|
options={options}
|
||||||
|
onChange={(e) => e != null && onChange(e.value)}
|
||||||
|
ref={ref}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ChannelSelect.displayName = 'ChannelSelect';
|
||||||
|
|
||||||
|
export const ChannelSelectForm: ControlledInput<Omit<Props, 'value' | 'onChange'>> = ({
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { field, fieldState } = useController(controller);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard {...control} error={fieldState.error?.message}>
|
||||||
|
<ChannelSelect {...field} {...props} />
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
};
|
114
src/components/forms/ColorPicker.tsx
Normal file
114
src/components/forms/ColorPicker.tsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import {
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftAddon,
|
||||||
|
Popover,
|
||||||
|
PopoverBody,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
SimpleGrid,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { HexAlphaColorPicker, HexColorPicker } from 'react-colorful';
|
||||||
|
import { ColorPickerBaseProps } from 'react-colorful/dist/types';
|
||||||
|
import { FormCard } from './Form';
|
||||||
|
import { convertHexToRGBA } from '@/utils/common';
|
||||||
|
import { useController } from 'react-hook-form';
|
||||||
|
import { ControlledInput } from './types';
|
||||||
|
|
||||||
|
export type ColorPickerFormProps = Omit<ColorPickerProps, 'value' | 'onChange'>;
|
||||||
|
|
||||||
|
export const SmallColorPickerForm: ControlledInput<
|
||||||
|
ColorPickerFormProps,
|
||||||
|
ColorPickerProps['value']
|
||||||
|
> = ({ control, controller, ...props }) => {
|
||||||
|
const { field, fieldState } = useController(controller);
|
||||||
|
const { value } = field;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard {...control} error={fieldState.error?.message}>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<InputGroup>
|
||||||
|
<InputLeftAddon bg={value} rounded="xl" h="full" />
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
variant="main"
|
||||||
|
placeholder={value ?? 'Select a color'}
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent>
|
||||||
|
<PopoverBody>
|
||||||
|
<ColorPicker value={value} onChange={field.onChange} {...props} />
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColorPickerForm: ControlledInput<ColorPickerFormProps, ColorPickerProps['value']> = ({
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { field, fieldState } = useController(controller);
|
||||||
|
const { value } = field;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard {...control} error={fieldState.error?.message}>
|
||||||
|
<SimpleGrid columns={{ base: 1, '3sm': 2 }} gap={2}>
|
||||||
|
<Flex direction="column" gap={3}>
|
||||||
|
<Center
|
||||||
|
display={{ base: 'none', '3sm': 'flex' }}
|
||||||
|
minH="150px"
|
||||||
|
rounded="xl"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="InputBorder"
|
||||||
|
bgColor={value == null ? 'InputBackground' : convertHexToRGBA(value)}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{value == null && (
|
||||||
|
<Text fontSize="sm" color="TextSecondary">
|
||||||
|
No Color
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Center>
|
||||||
|
<Input
|
||||||
|
placeholder={value ?? 'Select a color'}
|
||||||
|
variant="main"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<ColorPicker value={field.value} onChange={field.onChange} {...props} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPickerProps = {
|
||||||
|
value?: string | null;
|
||||||
|
onChange?: (color: string) => void;
|
||||||
|
supportAlpha?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ColorPicker({ value, onChange, supportAlpha, ...rest }: ColorPickerProps) {
|
||||||
|
const props: Partial<ColorPickerBaseProps<string>> = {
|
||||||
|
color: value ?? undefined,
|
||||||
|
onChange,
|
||||||
|
style: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
|
||||||
|
return supportAlpha ? <HexAlphaColorPicker {...props} /> : <HexColorPicker {...props} />;
|
||||||
|
}
|
84
src/components/forms/DatePicker.tsx
Normal file
84
src/components/forms/DatePicker.tsx
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { Calendar, CalendarProps } from 'react-calendar';
|
||||||
|
import { FormCard } from './Form';
|
||||||
|
import { ControlledInput } from './types';
|
||||||
|
import { Icon } from '@chakra-ui/react';
|
||||||
|
import { MdChevronLeft, MdChevronRight } from 'react-icons/md';
|
||||||
|
import { Text } from '@chakra-ui/layout';
|
||||||
|
import {
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputRightElement,
|
||||||
|
Popover,
|
||||||
|
PopoverBody,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { AiTwotoneCalendar as CalendarIcon } from 'react-icons/ai';
|
||||||
|
import { useController } from 'react-hook-form';
|
||||||
|
|
||||||
|
export function DatePicker(props: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<Calendar
|
||||||
|
view={'month'}
|
||||||
|
tileContent={<Text color="brand.500" />}
|
||||||
|
prevLabel={<Icon as={MdChevronLeft} w="24px" h="24px" mt="4px" />}
|
||||||
|
nextLabel={<Icon as={MdChevronRight} w="24px" h="24px" mt="4px" />}
|
||||||
|
{...props}
|
||||||
|
value={props.value ?? null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatePickerFormProps = Omit<CalendarProps, 'value' | 'onChange'>;
|
||||||
|
|
||||||
|
export const DatePickerForm: ControlledInput<DatePickerFormProps, CalendarProps['value']> = ({
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
field: { ref, ...field },
|
||||||
|
fieldState,
|
||||||
|
} = useController(controller);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard {...control} error={fieldState.error?.message}>
|
||||||
|
<DatePicker inputRef={ref} {...field} {...props} />
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SmallDatePickerForm: ControlledInput<DatePickerFormProps, CalendarProps['value']> = ({
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
field: { ref, ...field },
|
||||||
|
fieldState,
|
||||||
|
} = useController(controller);
|
||||||
|
|
||||||
|
const text = field.value?.toLocaleString(undefined, {
|
||||||
|
dateStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard {...control} error={fieldState.error?.message}>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<InputGroup>
|
||||||
|
<Input value={text ?? ''} placeholder="Select a Date" variant="main" readOnly />
|
||||||
|
<InputRightElement zIndex={0}>
|
||||||
|
<CalendarIcon />
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<PopoverBody>
|
||||||
|
<DatePicker inputRef={ref} {...field} {...props} />
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
};
|
96
src/components/forms/FilePicker.tsx
Normal file
96
src/components/forms/FilePicker.tsx
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { Box, Center, Flex, Text, VStack } from '@chakra-ui/layout';
|
||||||
|
import { Icon, Image, useFormControl } from '@chakra-ui/react';
|
||||||
|
import { ComponentProps } from 'react';
|
||||||
|
import Dropzone, { DropzoneOptions } from 'react-dropzone';
|
||||||
|
import { FaFile } from 'react-icons/fa';
|
||||||
|
import { MdUpload } from 'react-icons/md';
|
||||||
|
import { FormCard } from './Form';
|
||||||
|
import { useController } from 'react-hook-form';
|
||||||
|
import { ControlledInput } from './types';
|
||||||
|
import { useFileUrl } from '@/utils/use-file-url';
|
||||||
|
|
||||||
|
export type FilePickerFormProps = {
|
||||||
|
options?: DropzoneOptions;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilePickerForm: ControlledInput<FilePickerFormProps, File[] | undefined | null> = ({
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
field: { value, onChange, ref, ...field },
|
||||||
|
fieldState,
|
||||||
|
} = useController(controller);
|
||||||
|
|
||||||
|
const empty = value == null || value.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard {...control} error={fieldState.error?.message}>
|
||||||
|
<Dropzone ref={ref} {...options} onDrop={onChange}>
|
||||||
|
{({ getInputProps, getRootProps }) => (
|
||||||
|
<Box
|
||||||
|
bg="InputBackground"
|
||||||
|
border="1px dashed"
|
||||||
|
borderColor="InputBorder"
|
||||||
|
borderRadius="16px"
|
||||||
|
w="100%"
|
||||||
|
p={5}
|
||||||
|
cursor="pointer"
|
||||||
|
{...getRootProps()}
|
||||||
|
>
|
||||||
|
<Input input={getInputProps(field)} />
|
||||||
|
{empty ? (
|
||||||
|
<VStack
|
||||||
|
color="secondaryGray.700"
|
||||||
|
textAlign="center"
|
||||||
|
_dark={{ color: 'secondaryGray.600' }}
|
||||||
|
>
|
||||||
|
<Icon as={MdUpload} w="70px" h="70px" />
|
||||||
|
<Text fontWeight="500">{placeholder ?? 'Upload Files'}</Text>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Flex direction="column" gap={2}>
|
||||||
|
{(value as File[])?.map((file, i) => (
|
||||||
|
<FilePreview key={i} file={file} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Input({ input }: { input: ComponentProps<'input'> }) {
|
||||||
|
const inputProps = useFormControl<HTMLInputElement>(input);
|
||||||
|
|
||||||
|
return <input {...inputProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePreview({ file }: { file: File }) {
|
||||||
|
const url = useFileUrl(file);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="row" gap={2} w="full" align="center">
|
||||||
|
{file.type.startsWith('image/') ? (
|
||||||
|
<Image alt={file.name} maxW="70px" maxH="70px" src={url} rounded="md" />
|
||||||
|
) : (
|
||||||
|
<Center rounded="2xl" bg="brand.300" w="50px" h="50px">
|
||||||
|
<Icon as={FaFile} color="white" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
<VStack align="start" flex={1} spacing="3px">
|
||||||
|
<Text fontSize="md" fontWeight="600" color="TextPrimary">
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="TextSecondary">
|
||||||
|
{file.size} bytes
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
92
src/components/forms/Form.tsx
Normal file
92
src/components/forms/Form.tsx
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormControlProps,
|
||||||
|
FormErrorMessage,
|
||||||
|
FormLabel,
|
||||||
|
} from '@chakra-ui/form-control';
|
||||||
|
import { Flex, Spacer, Text } from '@chakra-ui/layout';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldValues,
|
||||||
|
Path,
|
||||||
|
UseControllerProps,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
|
||||||
|
export function Form(props: FormControlProps) {
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
as={Flex}
|
||||||
|
direction="column"
|
||||||
|
bg="CardBackground"
|
||||||
|
rounded="3xl"
|
||||||
|
p={5}
|
||||||
|
boxShadow="normal"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormCardProps = {
|
||||||
|
required?: boolean;
|
||||||
|
baseControl?: FormControlProps;
|
||||||
|
/**
|
||||||
|
* Show an error message if not null
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
label?: string | ReactNode;
|
||||||
|
description?: string | ReactNode;
|
||||||
|
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FormCard({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
baseControl,
|
||||||
|
children,
|
||||||
|
error,
|
||||||
|
}: FormCardProps) {
|
||||||
|
return (
|
||||||
|
<Form isRequired={required} isInvalid={error != null} {...baseControl}>
|
||||||
|
<FormLabel fontSize={{ base: '16px', md: 'lg' }} fontWeight="medium" mb={0}>
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
<Text fontSize={{ base: 'sm', md: 'md' }} color="TextSecondary">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
<Spacer mt={2} />
|
||||||
|
{children}
|
||||||
|
<FormErrorMessage>{error}</FormErrorMessage>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormCardControllerProps<
|
||||||
|
TFieldValue extends FieldValues,
|
||||||
|
TName extends Path<TFieldValue>
|
||||||
|
> = {
|
||||||
|
control: Omit<FormCardProps, 'error' | 'children'>;
|
||||||
|
controller: UseControllerProps<TFieldValue, TName>;
|
||||||
|
render: ControllerProps<TFieldValue, TName>['render'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FormCardController<
|
||||||
|
TFieldValue extends FieldValues,
|
||||||
|
TName extends Path<TFieldValue>
|
||||||
|
>({ control, controller, render }: FormCardControllerProps<TFieldValue, TName>) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
{...controller}
|
||||||
|
render={(props) => (
|
||||||
|
<FormCard {...control} error={props.fieldState.error?.message}>
|
||||||
|
{render(props)}
|
||||||
|
</FormCard>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
18
src/components/forms/InputForm.tsx
Normal file
18
src/components/forms/InputForm.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { Input, InputProps } from '@chakra-ui/react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { FormCard } from './Form';
|
||||||
|
import { WithControl } from './types';
|
||||||
|
|
||||||
|
export type InputFormProps = WithControl<InputProps>;
|
||||||
|
|
||||||
|
export const InputForm = forwardRef<HTMLInputElement, InputFormProps>(
|
||||||
|
({ control, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<FormCard {...control}>
|
||||||
|
<Input variant="main" ref={ref} {...props} />
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
InputForm.displayName = 'InputForm';
|
75
src/components/forms/RoleSelect.tsx
Normal file
75
src/components/forms/RoleSelect.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import { Icon, Image } from '@chakra-ui/react';
|
||||||
|
import { useGuildRolesQuery } from '@/api/hooks';
|
||||||
|
import { Option, SelectField } from '@/components/forms/SelectField';
|
||||||
|
import { toRGB } from '@/utils/common';
|
||||||
|
import { Role } from '@/api/bot';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { Params } from '@/pages/guilds/[guild]/features/[feature]';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { SelectInstance, Props as SelectProps } from 'chakra-react-select';
|
||||||
|
import { Override } from '@/utils/types';
|
||||||
|
import { ControlledInput } from './types';
|
||||||
|
import { FormCard } from './Form';
|
||||||
|
import { useController } from 'react-hook-form';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
|
||||||
|
import { BsPeopleFill } from 'react-icons/bs';
|
||||||
|
|
||||||
|
type Props = Override<
|
||||||
|
SelectProps<Option, false>,
|
||||||
|
{
|
||||||
|
value?: string;
|
||||||
|
onChange: (role: string) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
function render(role: Role): Option {
|
||||||
|
return {
|
||||||
|
value: role.id,
|
||||||
|
label: role.name,
|
||||||
|
icon:
|
||||||
|
role.icon?.iconUrl != null ? (
|
||||||
|
<Image alt="icon" src={role.icon.iconUrl} bg={toRGB(role.color)} w="25px" h="25px" />
|
||||||
|
) : (
|
||||||
|
<Icon as={BsPeopleFill} color={toRGB(role.color)} w="20px" h="20px" />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoleSelect = forwardRef<SelectInstance<Option, false>, Props>((props, ref) => {
|
||||||
|
const { value, onChange, ...rest } = props;
|
||||||
|
const { guild } = useRouter().query as Params;
|
||||||
|
const rolesQuery = useGuildRolesQuery(guild);
|
||||||
|
const isLoading = rolesQuery.isLoading;
|
||||||
|
|
||||||
|
const selected = value != null ? rolesQuery.data?.find((role) => role.id === value) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectField<Option>
|
||||||
|
isDisabled={isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
placeholder={<common.T text="select role" />}
|
||||||
|
value={selected != null ? render(selected) : null}
|
||||||
|
onChange={(e) => e != null && onChange(e.value)}
|
||||||
|
options={rolesQuery.data?.map(render)}
|
||||||
|
ref={ref}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RoleSelect.displayName = 'RolesSelect';
|
||||||
|
|
||||||
|
export const RoleSelectForm: ControlledInput<Omit<Props, 'value' | 'onChange'>> = ({
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { fieldState, field } = useController(controller);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard {...control} error={fieldState?.error?.message}>
|
||||||
|
<RoleSelect {...field} {...props} />
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
};
|
54
src/components/forms/SearchBar.tsx
Normal file
54
src/components/forms/SearchBar.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import {
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputGroupProps,
|
||||||
|
InputLeftElement,
|
||||||
|
InputProps,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { AiOutlineSearch as SearchIcon } from 'react-icons/ai';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
|
||||||
|
export function SearchBar(
|
||||||
|
props: {
|
||||||
|
input?: InputProps;
|
||||||
|
onSearch?: () => void;
|
||||||
|
} & InputGroupProps
|
||||||
|
) {
|
||||||
|
const t = common.useTranslations();
|
||||||
|
const { input, onSearch, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroup {...rest}>
|
||||||
|
<InputLeftElement>
|
||||||
|
<IconButton
|
||||||
|
aria-label="search"
|
||||||
|
bg="inherit"
|
||||||
|
borderRadius="inherit"
|
||||||
|
_active={{}}
|
||||||
|
variant="ghost"
|
||||||
|
icon={<Icon as={SearchIcon} color="TextPrimary" width="15px" height="15px" />}
|
||||||
|
onClick={onSearch}
|
||||||
|
/>
|
||||||
|
</InputLeftElement>
|
||||||
|
<Input
|
||||||
|
variant="search"
|
||||||
|
fontSize="sm"
|
||||||
|
bg="secondaryGray.300"
|
||||||
|
color="TextPrimary"
|
||||||
|
fontWeight="500"
|
||||||
|
_placeholder={{ color: 'gray.400', fontSize: '14px' }}
|
||||||
|
borderRadius="30px"
|
||||||
|
placeholder={`${t.search}...`}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') onSearch?.();
|
||||||
|
}}
|
||||||
|
_dark={{
|
||||||
|
bg: 'navy.900',
|
||||||
|
}}
|
||||||
|
{...input}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
}
|
119
src/components/forms/SelectField.tsx
Normal file
119
src/components/forms/SelectField.tsx
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { Box, HStack } from '@chakra-ui/layout';
|
||||||
|
import {
|
||||||
|
chakraComponents,
|
||||||
|
ChakraStylesConfig,
|
||||||
|
OptionBase,
|
||||||
|
Props,
|
||||||
|
Select,
|
||||||
|
SelectComponent,
|
||||||
|
SelectInstance,
|
||||||
|
} from 'chakra-react-select';
|
||||||
|
import { forwardRef, ReactNode } from 'react';
|
||||||
|
import { dark, light } from '@/theme/colors';
|
||||||
|
import { useColorModeValue } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
const customComponents = {
|
||||||
|
SingleValue: ({ children, ...props }: any) => {
|
||||||
|
return (
|
||||||
|
<chakraComponents.SingleValue {...props}>
|
||||||
|
<HStack>
|
||||||
|
{props.data.icon}
|
||||||
|
<span>{children}</span>
|
||||||
|
</HStack>
|
||||||
|
</chakraComponents.SingleValue>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Option: ({ children, ...props }: any) => {
|
||||||
|
return (
|
||||||
|
<chakraComponents.Option {...props}>
|
||||||
|
<Box mr={2}>{props.data.icon}</Box> {children}
|
||||||
|
</chakraComponents.Option>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles: ChakraStylesConfig<any, any, any> = {
|
||||||
|
menuList: (provided) => ({
|
||||||
|
...provided,
|
||||||
|
_light: {
|
||||||
|
...(provided as any)._light,
|
||||||
|
shadow: light.shadow,
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
...(provided as any)._dark,
|
||||||
|
shadow: dark.shadow,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
placeholder: (provided) => ({
|
||||||
|
...provided,
|
||||||
|
_light: {
|
||||||
|
color: 'secondaryGray.700',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
color: 'secondaryGray.600',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
dropdownIndicator: (provided) => ({
|
||||||
|
...provided,
|
||||||
|
bg: 'transparent',
|
||||||
|
}),
|
||||||
|
groupHeading: (provided) => ({
|
||||||
|
...provided,
|
||||||
|
_light: {
|
||||||
|
bg: 'secondaryGray.100',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: 'navy.800',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
option: (provided, options) => ({
|
||||||
|
...provided,
|
||||||
|
color: options.isSelected && 'white',
|
||||||
|
_light: {
|
||||||
|
bg: options.isSelected && light.brand,
|
||||||
|
_hover: {
|
||||||
|
bg: options.isSelected ? light.brand : 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: options.isSelected && dark.brand,
|
||||||
|
_hover: {
|
||||||
|
bg: options.isSelected ? dark.brand : 'whiteAlpha.200',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
control: (provided, data) => ({
|
||||||
|
...provided,
|
||||||
|
rounded: '2xl',
|
||||||
|
_light: {
|
||||||
|
borderColor: data.isFocused ? light.brand : 'secondaryGray.500',
|
||||||
|
bg: 'secondaryGray.300',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
borderColor: data.isFocused ? dark.brand : 'navy.600',
|
||||||
|
bg: 'blackAlpha.300',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Option = OptionBase & {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectFieldBase = forwardRef<SelectInstance, Props>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<Select<any, any, any>
|
||||||
|
focusBorderColor={useColorModeValue(light.brand, dark.brand)}
|
||||||
|
components={customComponents}
|
||||||
|
chakraStyles={styles}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SelectFieldBase.displayName = 'SelectField';
|
||||||
|
|
||||||
|
export const SelectField = SelectFieldBase as SelectComponent;
|
64
src/components/forms/SwitchField.tsx
Normal file
64
src/components/forms/SwitchField.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
// Chakra imports
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
FormErrorMessage,
|
||||||
|
FormLabel,
|
||||||
|
Switch,
|
||||||
|
SwitchProps,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useController } from 'react-hook-form';
|
||||||
|
import { Form } from './Form';
|
||||||
|
import { ControlledInput } from './types';
|
||||||
|
|
||||||
|
export const SwitchFieldForm: ControlledInput<{}, boolean> = ({
|
||||||
|
control,
|
||||||
|
controller,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
field: { value, ...field },
|
||||||
|
fieldState,
|
||||||
|
} = useController(controller);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form isInvalid={fieldState.invalid} isRequired={control.required} {...control.baseControl}>
|
||||||
|
<Flex justify="space-between" align="center" borderRadius="16px" gap={3}>
|
||||||
|
<Box>
|
||||||
|
<FormLabel fontSize={{ base: '16px', md: 'lg' }} fontWeight="medium" mb={0}>
|
||||||
|
{control.label}
|
||||||
|
</FormLabel>
|
||||||
|
<Text fontSize={{ base: 'sm', md: 'md' }} color="TextSecondary">
|
||||||
|
{control.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Switch variant="main" size="md" isChecked={value} {...field} {...props} />
|
||||||
|
</Flex>
|
||||||
|
<FormErrorMessage>{fieldState.error?.message}</FormErrorMessage>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwitchField(
|
||||||
|
props: {
|
||||||
|
id?: string;
|
||||||
|
label?: ReactNode;
|
||||||
|
desc?: ReactNode;
|
||||||
|
} & SwitchProps
|
||||||
|
) {
|
||||||
|
const { id, label, desc, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex justify="space-between" align="center" borderRadius="16px" gap={6}>
|
||||||
|
<Box>
|
||||||
|
<FormLabel htmlFor={id} fontSize="md" fontWeight="medium" mb={0}>
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
<Text color="TextSecondary">{desc}</Text>
|
||||||
|
</Box>
|
||||||
|
<Switch id={id} variant="main" size="md" {...rest} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
18
src/components/forms/TextAreaForm.tsx
Normal file
18
src/components/forms/TextAreaForm.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { FormCard } from './Form';
|
||||||
|
import { WithControl } from './types';
|
||||||
|
|
||||||
|
export type TextAreaFormProps = WithControl<TextareaProps>;
|
||||||
|
|
||||||
|
export const TextAreaForm = forwardRef<HTMLTextAreaElement, TextAreaFormProps>(
|
||||||
|
({ control, ...input }, ref) => {
|
||||||
|
return (
|
||||||
|
<FormCard {...control}>
|
||||||
|
<Textarea variant="glass" {...input} ref={ref} />
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TextAreaForm.displayName = 'Textarea';
|
27
src/components/forms/types.ts
Normal file
27
src/components/forms/types.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import type { Override } from '@/utils/types';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { FieldValues, Path, UseControllerProps, FieldPathByValue } from 'react-hook-form';
|
||||||
|
import type { FormCardProps } from './Form';
|
||||||
|
|
||||||
|
type ControlledInputProps<
|
||||||
|
T,
|
||||||
|
TFieldValue extends FieldValues,
|
||||||
|
TName extends Path<TFieldValue>
|
||||||
|
> = Override<
|
||||||
|
T,
|
||||||
|
{
|
||||||
|
control: Omit<FormCardProps, 'error' | 'children'>;
|
||||||
|
controller: UseControllerProps<TFieldValue, TName>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ControlledInput<Props, V = unknown> = <
|
||||||
|
TFieldValues extends FieldValues,
|
||||||
|
TName extends FieldPathByValue<TFieldValues, V>
|
||||||
|
>(
|
||||||
|
props: ControlledInputProps<Props, TFieldValues, TName>
|
||||||
|
) => ReactElement;
|
||||||
|
|
||||||
|
export type WithControl<T> = T & {
|
||||||
|
control: Omit<FormCardProps, 'children'>;
|
||||||
|
};
|
15
src/components/layout/Separator.tsx
Normal file
15
src/components/layout/Separator.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { HStack, Box, Text } from '@chakra-ui/layout';
|
||||||
|
import { useColorModeValue } from '@chakra-ui/react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function HSeparator({ children }: { children: ReactNode }) {
|
||||||
|
const bg = useColorModeValue('gray.300', 'gray.600');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack>
|
||||||
|
<Box w="full" h="1px" bg={bg} />
|
||||||
|
<Text color="secondaryGray.600">{children}</Text>
|
||||||
|
<Box w="full" h="1px" bg={bg} />
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
64
src/components/layout/app.tsx
Normal file
64
src/components/layout/app.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { Box, Flex, Show } from '@chakra-ui/react';
|
||||||
|
import { QueryStatus } from '@/components/panel/QueryPanel';
|
||||||
|
import { useSelfUserQuery } from '@/api/hooks';
|
||||||
|
import { LoadingPanel } from '@/components/panel/LoadingPanel';
|
||||||
|
import { Navbar } from '@/components/layout/navbar';
|
||||||
|
import { Sidebar, SidebarResponsive } from './sidebar';
|
||||||
|
import { sidebarBreakpoint, navbarBreakpoint } from '@/theme/breakpoints';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { DefaultNavbar } from './navbar/default';
|
||||||
|
|
||||||
|
export default function AppLayout({
|
||||||
|
navbar,
|
||||||
|
children,
|
||||||
|
sidebar,
|
||||||
|
}: {
|
||||||
|
navbar?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
sidebar?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const query = useSelfUserQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="row" h="full">
|
||||||
|
<Sidebar sidebar={sidebar} />
|
||||||
|
<Show below={sidebarBreakpoint}>
|
||||||
|
<SidebarResponsive sidebar={sidebar} />
|
||||||
|
</Show>
|
||||||
|
<QueryStatus query={query} loading={<LoadingPanel />} error="Failed to load user info">
|
||||||
|
<Flex
|
||||||
|
pos="relative"
|
||||||
|
direction="column"
|
||||||
|
height="100%"
|
||||||
|
overflow="auto"
|
||||||
|
w="full"
|
||||||
|
maxWidth={{ base: '100%', xl: 'calc( 100% - 290px )' }}
|
||||||
|
maxHeight="100%"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
top={0}
|
||||||
|
mx="auto"
|
||||||
|
maxW="1200px"
|
||||||
|
zIndex="sticky"
|
||||||
|
pos="sticky"
|
||||||
|
w="full"
|
||||||
|
pt={{ [navbarBreakpoint]: '16px' }}
|
||||||
|
px={{ '3sm': '30px' }}
|
||||||
|
>
|
||||||
|
<Navbar>{navbar ?? <DefaultNavbar />}</Navbar>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
mx="auto"
|
||||||
|
w="full"
|
||||||
|
maxW="1200px"
|
||||||
|
flex={1}
|
||||||
|
my={{ base: '30px', [sidebarBreakpoint]: '50px' }}
|
||||||
|
px={{ base: '24px', '3sm': '30px' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</QueryStatus>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
54
src/components/layout/auth.tsx
Normal file
54
src/components/layout/auth.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
// Chakra imports
|
||||||
|
import { Box, HStack, Image, Spacer, Text } from '@chakra-ui/react';
|
||||||
|
import { config } from '@/config/common';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { SelectField } from '../forms/SelectField';
|
||||||
|
import { languages, names, useLang } from '@/config/translations/provider';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box w="full" h="full" overflow="auto" py={40} px={{ base: 5, lg: 10 }}>
|
||||||
|
{children}
|
||||||
|
<HStack
|
||||||
|
pos="fixed"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
w="full"
|
||||||
|
bg="MainBackground"
|
||||||
|
px={{ base: 5, lg: 10 }}
|
||||||
|
py={2}
|
||||||
|
>
|
||||||
|
{config.icon != null && <Image src={config.icon} boxSize={10} alt='Logo'/>}
|
||||||
|
<Text fontWeight="600" fontSize="lg">
|
||||||
|
{config.name}
|
||||||
|
</Text>
|
||||||
|
<Spacer />
|
||||||
|
<Box w="150px">
|
||||||
|
<LanguageSelect />
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LanguageSelect() {
|
||||||
|
const { lang, setLang } = useLang();
|
||||||
|
const t = common.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectField
|
||||||
|
id="lang"
|
||||||
|
value={{
|
||||||
|
label: names[lang],
|
||||||
|
value: lang,
|
||||||
|
}}
|
||||||
|
onChange={(e) => e != null && setLang(e.value)}
|
||||||
|
options={languages.map(({ name, key }) => ({
|
||||||
|
label: name,
|
||||||
|
value: key,
|
||||||
|
}))}
|
||||||
|
placeholder={t['select lang']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
18
src/components/layout/guild/get-guild-layout.tsx
Normal file
18
src/components/layout/guild/get-guild-layout.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import AppLayout from '../app';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import GuildNavbar from './guild-navbar';
|
||||||
|
import { InGuildSidebar } from './guild-sidebar';
|
||||||
|
|
||||||
|
export default function getGuildLayout({
|
||||||
|
back,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
back?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AppLayout navbar={<GuildNavbar back={back} />} sidebar={back ? <InGuildSidebar /> : undefined}>
|
||||||
|
{children}
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
68
src/components/layout/guild/guild-navbar.tsx
Normal file
68
src/components/layout/guild/guild-navbar.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { FaChevronLeft as ChevronLeftIcon } from 'react-icons/fa';
|
||||||
|
import { Box, Flex, Text } from '@chakra-ui/layout';
|
||||||
|
import { Avatar, Icon, SkeletonCircle } from '@chakra-ui/react';
|
||||||
|
import { iconUrl } from '@/api/discord';
|
||||||
|
import { useGuildPreview } from '@/api/hooks';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
import { sidebarBreakpoint } from '@/theme/breakpoints';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
export default function GuildNavbar({ back }: { back?: boolean }) {
|
||||||
|
const { guild: selected } = useRouter().query as { guild: string };
|
||||||
|
const { guild } = useGuildPreview(selected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex w="full" direction="row" alignItems="center">
|
||||||
|
<HorizontalCollapse in={back ?? false}>
|
||||||
|
<Box
|
||||||
|
as={Link}
|
||||||
|
href={`/guilds/${selected}`}
|
||||||
|
display={{ base: 'flex', [sidebarBreakpoint]: 'none' }}
|
||||||
|
pr={3}
|
||||||
|
py={3}
|
||||||
|
>
|
||||||
|
<Icon aria-label="back" as={ChevronLeftIcon} my="auto" fontSize="lg" />
|
||||||
|
</Box>
|
||||||
|
</HorizontalCollapse>
|
||||||
|
{guild == null ? (
|
||||||
|
<SkeletonCircle mr={3} />
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
name={guild?.name}
|
||||||
|
src={iconUrl(guild)}
|
||||||
|
display={{ base: 'none', [sidebarBreakpoint]: 'block' }}
|
||||||
|
mr={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
fontWeight="600"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
w="0"
|
||||||
|
flex={1}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{guild?.name}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HorizontalCollapse({ in: isOpen, children }: { in: boolean; children: ReactElement }) {
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
animate={isOpen ? 'open' : 'collapsed'}
|
||||||
|
exit="collapsed"
|
||||||
|
initial="collapsed"
|
||||||
|
variants={{
|
||||||
|
open: { opacity: 1, width: 'auto' },
|
||||||
|
collapsed: { opacity: 0, width: 0 },
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.4, ease: [0.04, 0.62, 0.23, 0.98] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
}
|
54
src/components/layout/guild/guild-sidebar.tsx
Normal file
54
src/components/layout/guild/guild-sidebar.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { FaChevronLeft as ChevronLeftIcon } from 'react-icons/fa';
|
||||||
|
import { Flex, HStack, Text, VStack } from '@chakra-ui/layout';
|
||||||
|
import { Icon, IconButton } from '@chakra-ui/react';
|
||||||
|
import { HSeparator } from '@/components/layout/Separator';
|
||||||
|
import { getFeatures } from '@/utils/common';
|
||||||
|
import { IoSettings } from 'react-icons/io5';
|
||||||
|
import { useGuildPreview } from '@/api/hooks';
|
||||||
|
import { sidebarBreakpoint } from '@/theme/breakpoints';
|
||||||
|
import { guild as view } from '@/config/translations/guild';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Params } from '@/pages/guilds/[guild]/features/[feature]';
|
||||||
|
import { SidebarItem } from '../sidebar/SidebarItem';
|
||||||
|
|
||||||
|
export function InGuildSidebar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { guild: guildId, feature: activeId } = router.query as Params;
|
||||||
|
const { guild } = useGuildPreview(guildId);
|
||||||
|
|
||||||
|
const t = view.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap={2} p={3}>
|
||||||
|
<HStack as={Link} cursor="pointer" mb={2} href={`/guilds/${guildId}`}>
|
||||||
|
<IconButton
|
||||||
|
display={{ base: 'none', [sidebarBreakpoint]: 'block' }}
|
||||||
|
icon={<Icon verticalAlign="middle" as={ChevronLeftIcon} />}
|
||||||
|
aria-label="back"
|
||||||
|
/>
|
||||||
|
<Text fontSize="lg" fontWeight="600">
|
||||||
|
{guild?.name}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<VStack align="stretch">
|
||||||
|
<SidebarItem
|
||||||
|
href={`/guilds/${guildId}/settings`}
|
||||||
|
active={router.route === `/guilds/[guild]/settings`}
|
||||||
|
icon={<Icon as={IoSettings} />}
|
||||||
|
name={t.bn.settings}
|
||||||
|
/>
|
||||||
|
<HSeparator>Features</HSeparator>
|
||||||
|
{getFeatures().map((feature) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={feature.id}
|
||||||
|
name={feature.name}
|
||||||
|
icon={feature.icon}
|
||||||
|
active={activeId === feature.id}
|
||||||
|
href={`/guilds/${guildId}/features/${feature.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
78
src/components/layout/navbar/default.tsx
Normal file
78
src/components/layout/navbar/default.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
// Chakra Imports
|
||||||
|
import { Breadcrumb, BreadcrumbItem, Flex, Icon, SkeletonText, Tag, Text } from '@chakra-ui/react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useActiveSidebarItem } from '@/utils/router';
|
||||||
|
import { IoHome } from 'react-icons/io5';
|
||||||
|
import { FaChevronRight as ChevronRightIcon } from 'react-icons/fa';
|
||||||
|
import { navbarBreakpoint } from '@/theme/breakpoints';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function DefaultNavbar() {
|
||||||
|
const activeItem = useActiveSidebarItem();
|
||||||
|
const breadcrumb = [
|
||||||
|
{
|
||||||
|
icon: (<IoHome />) as ReactNode,
|
||||||
|
text: (<common.T text="pages" />) as ReactNode,
|
||||||
|
href: '/user/home',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (activeItem != null)
|
||||||
|
breadcrumb.push({
|
||||||
|
icon: activeItem.icon,
|
||||||
|
text: <>{activeItem.name}</>,
|
||||||
|
href: activeItem.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
gap={{
|
||||||
|
base: 2,
|
||||||
|
[navbarBreakpoint]: 3,
|
||||||
|
}}
|
||||||
|
mt={{
|
||||||
|
base: '8px',
|
||||||
|
[navbarBreakpoint]: '0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Breadcrumb
|
||||||
|
fontSize="sm"
|
||||||
|
separator={
|
||||||
|
<Icon
|
||||||
|
verticalAlign="middle"
|
||||||
|
as={ChevronRightIcon}
|
||||||
|
color="brand.500"
|
||||||
|
_dark={{
|
||||||
|
color: 'brand.100',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{breadcrumb.map((item, i) => (
|
||||||
|
<BreadcrumbItem key={i}>
|
||||||
|
<Tag
|
||||||
|
as={Link}
|
||||||
|
href={item.href}
|
||||||
|
gap={1}
|
||||||
|
rounded="full"
|
||||||
|
color="brand.500"
|
||||||
|
bg="brand.100"
|
||||||
|
_dark={{
|
||||||
|
color: 'brand.100',
|
||||||
|
bg: '#7551FF33',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<Text>{item.text}</Text>
|
||||||
|
</Tag>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
))}
|
||||||
|
</Breadcrumb>
|
||||||
|
<Text color="TextPrimary" fontWeight="bold" fontSize={{ base: '25px', '3sm': '34px' }} mb={2}>
|
||||||
|
{activeItem?.name || <SkeletonText w="full" noOfLines={2} />}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
52
src/components/layout/navbar/index.tsx
Normal file
52
src/components/layout/navbar/index.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { Flex, useColorModeValue } from '@chakra-ui/react';
|
||||||
|
import { navbarBreakpoint } from '@/theme/breakpoints';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { UserMenu } from '@/components/menu/UserMenu';
|
||||||
|
import { SidebarTrigger } from '@/components/SidebarTrigger';
|
||||||
|
import { ThemeSwitch } from '@/components/ThemeSwitch';
|
||||||
|
|
||||||
|
export function Navbar({ links, children }: { links?: ReactNode; children: ReactNode }) {
|
||||||
|
const navbarBg = useColorModeValue('rgba(244, 247, 254, 0.2)', 'rgba(8, 8, 28, 0.5)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
mx="auto"
|
||||||
|
bg={navbarBg}
|
||||||
|
backdropFilter="blur(20px)"
|
||||||
|
borderRadius={{ [navbarBreakpoint]: '16px' }}
|
||||||
|
lineHeight="25.6px"
|
||||||
|
px={{ base: '24px', [navbarBreakpoint]: 5 }}
|
||||||
|
py={{ base: '3px', [navbarBreakpoint]: '8px' }}
|
||||||
|
gap={2}
|
||||||
|
justify="space-between"
|
||||||
|
alignItems="stretch"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavbarLinksBox>{links}</NavbarLinksBox>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavbarLinksBox({ children }: { children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
justify="end"
|
||||||
|
align="center"
|
||||||
|
direction="row"
|
||||||
|
bg="CardBackground"
|
||||||
|
p="10px"
|
||||||
|
borderRadius="30px"
|
||||||
|
boxShadow="normal"
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<SidebarTrigger />
|
||||||
|
<ThemeSwitch secondary />
|
||||||
|
<UserMenu color="TextPrimary" shadow="normal" bg="CardBackground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
41
src/components/layout/sidebar/GuildItem.tsx
Normal file
41
src/components/layout/sidebar/GuildItem.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { Avatar, Card, CardBody, Flex, Skeleton, Text } from '@chakra-ui/react';
|
||||||
|
import { Guild, iconUrl } from '@/api/discord';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function GuildItem({
|
||||||
|
guild,
|
||||||
|
active,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
guild: Guild;
|
||||||
|
active: boolean;
|
||||||
|
href: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
bg={active ? 'Brand' : 'MainBackground'}
|
||||||
|
color={active ? 'white' : undefined}
|
||||||
|
cursor="pointer"
|
||||||
|
as={Link}
|
||||||
|
href={href}
|
||||||
|
rounded="xl"
|
||||||
|
>
|
||||||
|
<CardBody as={Flex} direction="column" gap={3}>
|
||||||
|
<Avatar name={guild.name} src={iconUrl(guild)} />
|
||||||
|
<Text fontWeight="600">{guild.name}</Text>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GuildItemsSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Skeleton h="124px" rounded="xl" />
|
||||||
|
<Skeleton h="124px" rounded="xl" />
|
||||||
|
<Skeleton h="124px" rounded="xl" />
|
||||||
|
<Skeleton h="124px" rounded="xl" />
|
||||||
|
<Skeleton h="124px" rounded="xl" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
119
src/components/layout/sidebar/SidebarContent.tsx
Normal file
119
src/components/layout/sidebar/SidebarContent.tsx
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Spacer,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { useActiveSidebarItem, SidebarItemInfo } from '@/utils/router';
|
||||||
|
import { useGuilds, useSelfUserQuery } from '@/api/hooks';
|
||||||
|
import { SearchBar } from '@/components/forms/SearchBar';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { config } from '@/config/common';
|
||||||
|
import { FiSettings as SettingsIcon } from 'react-icons/fi';
|
||||||
|
import { avatarUrl } from '@/api/discord';
|
||||||
|
import { GuildItem, GuildItemsSkeleton } from './GuildItem';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { SidebarItem } from './SidebarItem';
|
||||||
|
import items from '@/config/sidebar-items';
|
||||||
|
|
||||||
|
export function SidebarContent() {
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const guilds = useGuilds();
|
||||||
|
const { guild: selectedGroup } = useRouter().query as {
|
||||||
|
guild: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredGuilds = useMemo(
|
||||||
|
() =>
|
||||||
|
guilds.data?.filter((guild) => {
|
||||||
|
const contains = guild.name.toLowerCase().includes(filter.toLowerCase());
|
||||||
|
|
||||||
|
return config.guild.filter(guild) && contains;
|
||||||
|
}),
|
||||||
|
[guilds.data, filter]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VStack align="center" py="2rem" m={3} bg="Brand" rounded="xl">
|
||||||
|
<Heading size="lg" fontWeight={600} color="white">
|
||||||
|
{config.name}
|
||||||
|
</Heading>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Stack direction="column" mb="auto">
|
||||||
|
<Items />
|
||||||
|
<Box px="10px">
|
||||||
|
<SearchBar
|
||||||
|
w="full"
|
||||||
|
input={{
|
||||||
|
value: filter,
|
||||||
|
onChange: (e) => setFilter(e.target.value),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Flex direction="column" px="10px" gap={3}>
|
||||||
|
{filteredGuilds == null ? (
|
||||||
|
<GuildItemsSkeleton />
|
||||||
|
) : (
|
||||||
|
filteredGuilds?.map((guild) => (
|
||||||
|
<GuildItem
|
||||||
|
key={guild.id}
|
||||||
|
guild={guild}
|
||||||
|
active={selectedGroup === guild.id}
|
||||||
|
href={`/guilds/${guild.id}`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomCard() {
|
||||||
|
const user = useSelfUserQuery().data;
|
||||||
|
if (user == null) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card pos="sticky" left={0} bottom={0} w="full" py={2}>
|
||||||
|
<CardBody as={HStack}>
|
||||||
|
<Avatar src={avatarUrl(user)} name={user.username} size="sm" />
|
||||||
|
<Text fontWeight="600">{user.username}</Text>
|
||||||
|
<Spacer />
|
||||||
|
<Link href="/user/profile">
|
||||||
|
<IconButton icon={<SettingsIcon />} aria-label="settings" />
|
||||||
|
</Link>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Items() {
|
||||||
|
const active = useActiveSidebarItem();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" px="10px" gap={0}>
|
||||||
|
{items
|
||||||
|
.filter((item) => !item.hidden)
|
||||||
|
.map((route: SidebarItemInfo, index: number) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={index}
|
||||||
|
href={route.path}
|
||||||
|
name={route.name}
|
||||||
|
icon={route.icon}
|
||||||
|
active={active === route}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
59
src/components/layout/sidebar/SidebarItem.tsx
Normal file
59
src/components/layout/sidebar/SidebarItem.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { Center, StackProps, HStack, Text } from '@chakra-ui/layout';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function SidebarItem({
|
||||||
|
name,
|
||||||
|
active,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
name: ReactNode;
|
||||||
|
icon: ReactNode;
|
||||||
|
active: boolean;
|
||||||
|
href: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CardItem active={active} href={href}>
|
||||||
|
<Center
|
||||||
|
p={2}
|
||||||
|
fontSize="sm"
|
||||||
|
bg={active ? 'brand.500' : 'transparent'}
|
||||||
|
rounded="xl"
|
||||||
|
color={active ? 'white' : 'TextPrimary'}
|
||||||
|
border="2px solid"
|
||||||
|
borderColor="blackAlpha.200"
|
||||||
|
boxShadow={`0px 0px 15px ${
|
||||||
|
active ? 'var(--chakra-colors-brandAlpha-500)' : 'rgba(112, 144, 176, 0.3)'
|
||||||
|
}`}
|
||||||
|
_dark={{
|
||||||
|
bg: active ? 'brand.400' : 'transparent',
|
||||||
|
borderColor: 'whiteAlpha.400',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Center>
|
||||||
|
<Text fontSize="md" fontWeight="medium">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</CardItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardItem({ active, href, ...props }: { href: string; active: boolean } & StackProps) {
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
as={Link}
|
||||||
|
href={href}
|
||||||
|
rounded="xl"
|
||||||
|
p={2}
|
||||||
|
color={active ? 'TextPrimary' : 'TextSecondary'}
|
||||||
|
bg={active ? 'MainBackground' : undefined}
|
||||||
|
_dark={{
|
||||||
|
bg: active ? 'whiteAlpha.100' : undefined,
|
||||||
|
}}
|
||||||
|
cursor="pointer"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
68
src/components/layout/sidebar/index.tsx
Normal file
68
src/components/layout/sidebar/index.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerBody,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerCloseButton,
|
||||||
|
Flex,
|
||||||
|
Spacer,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { BottomCard, SidebarContent } from './SidebarContent';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { usePageStore } from '@/stores';
|
||||||
|
import { sidebarBreakpoint } from '@/theme/breakpoints';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function Sidebar({ sidebar }: { sidebar?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
display={{ base: 'none', [sidebarBreakpoint]: 'flex' }}
|
||||||
|
flexShrink={0}
|
||||||
|
bg="CardBackground"
|
||||||
|
w="300px"
|
||||||
|
h="100%"
|
||||||
|
overflowX="hidden"
|
||||||
|
overflowY="auto"
|
||||||
|
>
|
||||||
|
<AnimatePresence exitBeforeEnter initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={sidebar == null ? 'default' : 'new'}
|
||||||
|
initial={{ x: '100px', opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: '-100px', opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
{sidebar ?? <SidebarContent />}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
<Spacer />
|
||||||
|
<BottomCard />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarResponsive({ sidebar }: { sidebar?: ReactNode }) {
|
||||||
|
const [isOpen, setOpen] = usePageStore((s) => [s.sidebarIsOpen, s.setSidebarIsOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer isOpen={isOpen} onClose={() => setOpen(false)}>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerContent w="285px" maxW="285px" bg="CardBackground">
|
||||||
|
<DrawerCloseButton
|
||||||
|
zIndex="3"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
_focus={{ boxShadow: 'none' }}
|
||||||
|
_hover={{ boxShadow: 'none' }}
|
||||||
|
/>
|
||||||
|
<DrawerBody maxW="285px" px="0rem" pb="0">
|
||||||
|
<Flex direction="column" height="100%" overflow="auto">
|
||||||
|
{sidebar ?? <SidebarContent />}
|
||||||
|
<Spacer />
|
||||||
|
<BottomCard />
|
||||||
|
</Flex>
|
||||||
|
</DrawerBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
88
src/components/menu/UserMenu.tsx
Normal file
88
src/components/menu/UserMenu.tsx
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Flex,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Text,
|
||||||
|
useColorModeValue,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { UserInfo, avatarUrl } from '@/api/discord';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSelfUser } from '@/api/hooks';
|
||||||
|
import { useLogoutMutation } from '@/utils/auth/hooks';
|
||||||
|
|
||||||
|
export function UserMenu(props: { color: string; shadow: string; bg: string }) {
|
||||||
|
const user = useSelfUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<MenuButton p="0px">
|
||||||
|
<Avatar
|
||||||
|
_hover={{ cursor: 'pointer' }}
|
||||||
|
color="white"
|
||||||
|
name={user.username}
|
||||||
|
src={avatarUrl(user)}
|
||||||
|
bg="#11047A"
|
||||||
|
w="40px"
|
||||||
|
h="40px"
|
||||||
|
/>
|
||||||
|
</MenuButton>
|
||||||
|
<List user={user} shadow={props.shadow} menuBg={props.bg} textColor={props.color} />
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function List(props: { textColor: string; shadow: string; menuBg: string; user: UserInfo }) {
|
||||||
|
const t = common.useTranslations();
|
||||||
|
const { menuBg, shadow, textColor, user } = props;
|
||||||
|
const borderColor = useColorModeValue('#E6ECFA', 'rgba(135, 140, 189, 0.3)');
|
||||||
|
const logout = useLogoutMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuList boxShadow={shadow} p="0px" mt="10px" borderRadius="20px" bg={menuBg} border="none">
|
||||||
|
<Flex w="100%" mb="0px">
|
||||||
|
<Text
|
||||||
|
ps="20px"
|
||||||
|
pt="16px"
|
||||||
|
pb="10px"
|
||||||
|
w="100%"
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor={borderColor}
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="700"
|
||||||
|
color={textColor}
|
||||||
|
>
|
||||||
|
<span aria-label="Hi" role="img">
|
||||||
|
👋
|
||||||
|
</span>
|
||||||
|
Hey, {user.username}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="column" p="10px">
|
||||||
|
<MenuItem
|
||||||
|
_hover={{ bg: 'none' }}
|
||||||
|
_focus={{ bg: 'none' }}
|
||||||
|
borderRadius="8px"
|
||||||
|
px="14px"
|
||||||
|
as={Link}
|
||||||
|
href={`/user/profile`}
|
||||||
|
>
|
||||||
|
<Text fontSize="sm">{t.profile}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
_hover={{ bg: 'none' }}
|
||||||
|
_focus={{ bg: 'none' }}
|
||||||
|
color="red.400"
|
||||||
|
borderRadius="8px"
|
||||||
|
onClick={() => logout.mutate()}
|
||||||
|
px="14px"
|
||||||
|
>
|
||||||
|
<Text fontSize="sm">{t.logout}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Flex>
|
||||||
|
</MenuList>
|
||||||
|
);
|
||||||
|
}
|
20
src/components/panel/ErrorPanel.tsx
Normal file
20
src/components/panel/ErrorPanel.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { Button, Center, Icon, Text, VStack } from '@chakra-ui/react';
|
||||||
|
import { MdOutlineError } from 'react-icons/md';
|
||||||
|
|
||||||
|
export function ErrorPanel({ children, retry }: { children: string; retry: () => void }) {
|
||||||
|
const red = 'red.400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center w="full" h="full">
|
||||||
|
<VStack>
|
||||||
|
<Icon color={red} as={MdOutlineError} w="100px" h="100px" />
|
||||||
|
<Text color={red} fontWeight="bold">
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
<Button variant="danger" onClick={retry}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
15
src/components/panel/LoadingPanel.tsx
Normal file
15
src/components/panel/LoadingPanel.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { Center, CenterProps, Spinner, Text, VStack } from '@chakra-ui/react';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
|
||||||
|
export function LoadingPanel(props: CenterProps) {
|
||||||
|
const t = common.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center w="full" h="full" {...props}>
|
||||||
|
<VStack>
|
||||||
|
<Spinner size="lg" />
|
||||||
|
<Text color="TextPrimary">{t.loading}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
24
src/components/panel/QueryPanel.tsx
Normal file
24
src/components/panel/QueryPanel.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { UseQueryResult } from '@tanstack/react-query';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { ErrorPanel } from './ErrorPanel';
|
||||||
|
|
||||||
|
export function QueryStatus({
|
||||||
|
query,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
query: UseQueryResult;
|
||||||
|
error: string;
|
||||||
|
/**
|
||||||
|
* element to display when loading
|
||||||
|
*/
|
||||||
|
loading: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
if (query.isError) return <ErrorPanel retry={() => query.refetch()}>{error}</ErrorPanel>;
|
||||||
|
if (query.isLoading) return <>{loading}</>;
|
||||||
|
if (query.isSuccess) return <>{children}</>;
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
}
|
14
src/config/common.tsx
Normal file
14
src/config/common.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { PermissionFlags } from '@/api/discord';
|
||||||
|
import { AppConfig } from './types';
|
||||||
|
|
||||||
|
export const config: AppConfig = {
|
||||||
|
name: 'VOTL Bot',
|
||||||
|
icon:
|
||||||
|
'https://cdn.fileeditor.dev/media/votl/logo.png',
|
||||||
|
inviteUrl:
|
||||||
|
`https://discord.com/oauth2/authorize?client_id=${process.env.BOT_CLIENT_ID}&permissions=8&integration_type=0&scope=bot+applications.commands`,
|
||||||
|
guild: {
|
||||||
|
//filter guilds that user has no permissions to manage it
|
||||||
|
filter: (guild) => (Number(guild.permissions) & PermissionFlags.ADMINISTRATOR) !== 0,
|
||||||
|
},
|
||||||
|
};
|
160
src/config/example/HomeView.tsx
Normal file
160
src/config/example/HomeView.tsx
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import {
|
||||||
|
Center,
|
||||||
|
Circle,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Icon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { config } from '@/config/common';
|
||||||
|
import { StyledChart } from '@/components/chart/StyledChart';
|
||||||
|
import { dashboard } from '@/config/translations/dashboard';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { BsMusicNoteBeamed } from 'react-icons/bs';
|
||||||
|
import { IoOpen, IoPricetag } from 'react-icons/io5';
|
||||||
|
import { FaRobot } from 'react-icons/fa';
|
||||||
|
import { MdVoiceChat } from 'react-icons/md';
|
||||||
|
import { GuildSelect } from '@/pages/user/home';
|
||||||
|
|
||||||
|
export default function HomeView() {
|
||||||
|
const t = dashboard.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap={5}>
|
||||||
|
<Flex direction="row" alignItems="center" rounded="2xl" bg="Brand" gap={4} p={5}>
|
||||||
|
<Circle
|
||||||
|
color="white"
|
||||||
|
bgGradient="linear(to right bottom, transparent, blackAlpha.600)"
|
||||||
|
p={4}
|
||||||
|
shadow="2xl"
|
||||||
|
display={{ base: 'none', md: 'block' }}
|
||||||
|
>
|
||||||
|
<Icon as={FaRobot} w="60px" h="60px" />
|
||||||
|
</Circle>
|
||||||
|
|
||||||
|
<Flex direction="column" align="start" gap={1}>
|
||||||
|
<Heading color="white" fontSize="2xl" fontWeight="bold">
|
||||||
|
{t.invite.title}
|
||||||
|
</Heading>
|
||||||
|
<Text color="whiteAlpha.800">{t.invite.description}</Text>
|
||||||
|
<Button
|
||||||
|
mt={3}
|
||||||
|
as={Link}
|
||||||
|
href={config.inviteUrl}
|
||||||
|
color="white"
|
||||||
|
bg="whiteAlpha.200"
|
||||||
|
_hover={{
|
||||||
|
bg: 'whiteAlpha.300',
|
||||||
|
}}
|
||||||
|
_active={{
|
||||||
|
bg: 'whiteAlpha.400',
|
||||||
|
}}
|
||||||
|
leftIcon={<IoOpen />}
|
||||||
|
>
|
||||||
|
{t.invite.bn}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex direction="column" gap={1} mt={3}>
|
||||||
|
<Heading size="md">{t.servers.title}</Heading>
|
||||||
|
<Text color="TextSecondary">{t.servers.description}</Text>
|
||||||
|
</Flex>
|
||||||
|
<GuildSelect />
|
||||||
|
|
||||||
|
<Flex direction="column" gap={1}>
|
||||||
|
<Heading size="md">{t.command.title}</Heading>
|
||||||
|
<Text color="TextSecondary">{t.command.description}</Text>
|
||||||
|
<HStack mt={3}>
|
||||||
|
<Button leftIcon={<IoPricetag />} variant="action">
|
||||||
|
{t.pricing}
|
||||||
|
</Button>
|
||||||
|
<Button px={6} rounded="xl" variant="secondary">
|
||||||
|
{t.learn_more}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
<TestChart />
|
||||||
|
<Grid templateColumns={{ base: '1fr', lg: '0.5fr 1fr' }} gap={3}>
|
||||||
|
<Card rounded="3xl" variant="primary">
|
||||||
|
<CardBody as={Center} p={4} flexDirection="column" gap={3}>
|
||||||
|
<Circle p={4} bg="brandAlpha.100" color="brand.500" _dark={{ color: 'brand.200' }}>
|
||||||
|
<Icon as={BsMusicNoteBeamed} w="80px" h="80px" />
|
||||||
|
</Circle>
|
||||||
|
<Text fontWeight="medium">{t.vc.create}</Text>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Flex direction="column" gap={3}>
|
||||||
|
<Text fontSize="lg" fontWeight="600">
|
||||||
|
{t.vc['created channels']}
|
||||||
|
</Text>
|
||||||
|
<VoiceChannelItem />
|
||||||
|
<VoiceChannelItem />
|
||||||
|
</Flex>
|
||||||
|
</Grid>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestChart() {
|
||||||
|
return (
|
||||||
|
<StyledChart
|
||||||
|
options={{
|
||||||
|
colors: ['#4318FF', '#39B8FF'],
|
||||||
|
chart: {
|
||||||
|
animations: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: ['SEP', 'OCT', 'NOV', 'DEC', 'JAN', 'FEB'],
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
responsive: [
|
||||||
|
{
|
||||||
|
breakpoint: 650,
|
||||||
|
options: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
series={[
|
||||||
|
{
|
||||||
|
name: 'Paid',
|
||||||
|
data: [50, 64, 48, 66, 49, 68],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Free Usage',
|
||||||
|
data: [30, 50, 13, 46, 26, 16],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
height="300"
|
||||||
|
type="line"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceChannelItem() {
|
||||||
|
return (
|
||||||
|
<Card rounded="2xl" variant="primary">
|
||||||
|
<CardHeader as={HStack}>
|
||||||
|
<Icon as={MdVoiceChat} color="Brand" fontSize={{ base: '2xl', md: '3xl' }} />
|
||||||
|
<Text>My Channel</Text>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody mt={3}>
|
||||||
|
<Text color="TextSecondary">89 Members</Text>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
68
src/config/example/MemeFeature.tsx
Normal file
68
src/config/example/MemeFeature.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { SimpleGrid } from '@chakra-ui/layout';
|
||||||
|
import { MemeFeature, UseFormRender, memeFeatureSchema } from '@/config/types';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { ChannelSelectForm } from '@/components/forms/ChannelSelect';
|
||||||
|
import { FormCardController } from '@/components/forms/Form';
|
||||||
|
import { SelectField } from '@/components/forms/SelectField';
|
||||||
|
import type { OptionBase } from 'chakra-react-select';
|
||||||
|
|
||||||
|
type Option = OptionBase & {
|
||||||
|
label: string;
|
||||||
|
value: Exclude<MemeFeature['source'], undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sources: Option[] = [
|
||||||
|
{ label: 'youtube', value: 'youtube' },
|
||||||
|
{ label: 'twitter', value: 'twitter' },
|
||||||
|
{ label: 'discord', value: 'discord' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useMemeFeature: UseFormRender<MemeFeature> = (data, onSubmit) => {
|
||||||
|
const { reset, handleSubmit, formState, control } = useForm<MemeFeature>({
|
||||||
|
resolver: zodResolver(memeFeatureSchema),
|
||||||
|
shouldUnregister: false,
|
||||||
|
defaultValues: {
|
||||||
|
channel: data.channel,
|
||||||
|
source: data.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: (
|
||||||
|
<SimpleGrid columns={{ base: 1, lg: 2 }} gap={3}>
|
||||||
|
<ChannelSelectForm
|
||||||
|
control={{
|
||||||
|
label: 'Channel',
|
||||||
|
description: 'Where to send the welcome message',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'channel' }}
|
||||||
|
/>
|
||||||
|
<FormCardController
|
||||||
|
control={{ label: 'Source', description: 'The source of the meme' }}
|
||||||
|
controller={{ control, name: 'source' }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<SelectField<Option>
|
||||||
|
{...field}
|
||||||
|
value={field.value != null ? sources.find((v) => v.value === field.value) : undefined}
|
||||||
|
onChange={(v) => v && field.onChange(v.value)}
|
||||||
|
options={sources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
),
|
||||||
|
onSubmit: handleSubmit(async (e) => {
|
||||||
|
const data = await onSubmit(
|
||||||
|
JSON.stringify({
|
||||||
|
channel: e.channel,
|
||||||
|
source: e.source,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
reset(data);
|
||||||
|
}),
|
||||||
|
canSave: formState.isDirty,
|
||||||
|
reset: () => reset(control._defaultValues),
|
||||||
|
};
|
||||||
|
};
|
109
src/config/example/WelcomeMessageFeature.tsx
Normal file
109
src/config/example/WelcomeMessageFeature.tsx
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { SimpleGrid } from '@chakra-ui/layout';
|
||||||
|
import { TextAreaForm } from '@/components/forms/TextAreaForm';
|
||||||
|
import { UseFormRender, WelcomeMessageFeature } from '@/config/types';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ColorPickerForm, SmallColorPickerForm } from '@/components/forms/ColorPicker';
|
||||||
|
import { DatePickerForm } from '@/components/forms/DatePicker';
|
||||||
|
import { FilePickerForm } from '@/components/forms/FilePicker';
|
||||||
|
import { SwitchFieldForm } from '@/components/forms/SwitchField';
|
||||||
|
import { ChannelSelectForm } from '@/components/forms/ChannelSelect';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
message: z.string().min(20),
|
||||||
|
channel: z.string(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
date: z.date().optional(),
|
||||||
|
file: z.custom<File[]>().optional(),
|
||||||
|
danger: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Input = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export const useWelcomeMessageFeature: UseFormRender<WelcomeMessageFeature> = (data, onSubmit) => {
|
||||||
|
const { register, reset, handleSubmit, formState, control } = useForm<Input>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
shouldUnregister: false,
|
||||||
|
defaultValues: {
|
||||||
|
channel: data.channel,
|
||||||
|
message: data.message ?? '',
|
||||||
|
color: undefined,
|
||||||
|
date: undefined,
|
||||||
|
file: [],
|
||||||
|
danger: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: (
|
||||||
|
<SimpleGrid columns={{ base: 1, lg: 2 }} gap={3}>
|
||||||
|
<ChannelSelectForm
|
||||||
|
control={{
|
||||||
|
label: 'Channel',
|
||||||
|
description: 'Where to send the welcome message',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'channel' }}
|
||||||
|
/>
|
||||||
|
<TextAreaForm
|
||||||
|
control={{
|
||||||
|
label: 'Message',
|
||||||
|
description: 'The message to send',
|
||||||
|
error: formState.errors.message?.message,
|
||||||
|
}}
|
||||||
|
placeholder="Type some text here..."
|
||||||
|
{...register('message')}
|
||||||
|
/>
|
||||||
|
<SmallColorPickerForm
|
||||||
|
control={{
|
||||||
|
label: 'Color',
|
||||||
|
description: 'The color of message',
|
||||||
|
}}
|
||||||
|
supportAlpha
|
||||||
|
controller={{ control, name: 'color' }}
|
||||||
|
/>
|
||||||
|
<FilePickerForm
|
||||||
|
control={{
|
||||||
|
label: 'File',
|
||||||
|
description: 'The file to upload',
|
||||||
|
}}
|
||||||
|
options={{ accept: { 'image/*': [] }, multiple: false }}
|
||||||
|
controller={{ control, name: 'file' }}
|
||||||
|
/>
|
||||||
|
<ColorPickerForm
|
||||||
|
control={{
|
||||||
|
label: 'Color',
|
||||||
|
description: 'The color of message',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'color' }}
|
||||||
|
/>
|
||||||
|
<DatePickerForm
|
||||||
|
control={{
|
||||||
|
label: 'Date',
|
||||||
|
description: 'The date of today',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'date' }}
|
||||||
|
/>
|
||||||
|
<SwitchFieldForm
|
||||||
|
control={{ label: 'Turn on', description: 'Enable something' }}
|
||||||
|
controller={{
|
||||||
|
control,
|
||||||
|
name: 'danger',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
),
|
||||||
|
onSubmit: handleSubmit(async (e) => {
|
||||||
|
const data = await onSubmit(
|
||||||
|
JSON.stringify({
|
||||||
|
message: e.message,
|
||||||
|
channel: e.channel,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
reset(data);
|
||||||
|
}),
|
||||||
|
canSave: formState.isDirty,
|
||||||
|
reset: () => reset(control._defaultValues),
|
||||||
|
};
|
||||||
|
};
|
79
src/config/features.tsx
Normal file
79
src/config/features.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { Icon } from '@chakra-ui/react';
|
||||||
|
import { BsMusicNoteBeamed } from 'react-icons/bs';
|
||||||
|
import { FaGamepad } from 'react-icons/fa';
|
||||||
|
import { IoHappy } from 'react-icons/io5';
|
||||||
|
import { MdAddReaction, MdMessage } from 'react-icons/md';
|
||||||
|
import { FeaturesConfig } from './types';
|
||||||
|
import { provider } from '@/config/translations/provider';
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
import { useWelcomeMessageFeature } from './example/WelcomeMessageFeature';
|
||||||
|
import { useMemeFeature } from './example/MemeFeature';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support i18n (Localization)
|
||||||
|
*/
|
||||||
|
const { T } = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
music: 'Music Player',
|
||||||
|
'music description': 'Play music in Your Discord Server',
|
||||||
|
gaming: 'Gaming',
|
||||||
|
'gaming description': 'Enjoy playing games with your friends',
|
||||||
|
'reaction role': 'Reaction Role',
|
||||||
|
'reaction role description': 'Give user a role when clicking on a button',
|
||||||
|
memes: 'Memes Time',
|
||||||
|
'memes description': 'Send memes everyday',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define information for each features
|
||||||
|
*
|
||||||
|
* There is an example:
|
||||||
|
*/
|
||||||
|
export const features: FeaturesConfig = {
|
||||||
|
music: {
|
||||||
|
name: <T text="music" />,
|
||||||
|
description: <T text="music description" />,
|
||||||
|
icon: <Icon as={BsMusicNoteBeamed} />,
|
||||||
|
useRender() {
|
||||||
|
return {
|
||||||
|
component: <></>,
|
||||||
|
onSubmit: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'welcome-message': {
|
||||||
|
name: 'Welcome Message',
|
||||||
|
description: 'Send message when user joined the server',
|
||||||
|
icon: <Icon as={MdMessage} />,
|
||||||
|
useRender: useWelcomeMessageFeature,
|
||||||
|
},
|
||||||
|
gaming: {
|
||||||
|
name: <T text="gaming" />,
|
||||||
|
description: <T text="gaming description" />,
|
||||||
|
icon: <Icon as={FaGamepad} />,
|
||||||
|
useRender() {
|
||||||
|
return {
|
||||||
|
component: <></>,
|
||||||
|
onSubmit: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'reaction-role': {
|
||||||
|
name: <T text="reaction role" />,
|
||||||
|
description: <T text="reaction role description" />,
|
||||||
|
icon: <Icon as={MdAddReaction} />,
|
||||||
|
useRender() {
|
||||||
|
return {
|
||||||
|
component: <></>,
|
||||||
|
onSubmit: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meme: {
|
||||||
|
name: <T text="memes" />,
|
||||||
|
description: <T text="memes description" />,
|
||||||
|
icon: <Icon as={IoHappy} />,
|
||||||
|
useRender: useMemeFeature,
|
||||||
|
},
|
||||||
|
};
|
19
src/config/sidebar-items.tsx
Normal file
19
src/config/sidebar-items.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Icon } from '@chakra-ui/react';
|
||||||
|
import { common } from '@/config/translations/common';
|
||||||
|
import { MdPerson, MdDashboard } from 'react-icons/md';
|
||||||
|
import { SidebarItemInfo } from '@/utils/router';
|
||||||
|
|
||||||
|
const items: SidebarItemInfo[] = [
|
||||||
|
{
|
||||||
|
name: <common.T text="dashboard" />,
|
||||||
|
path: '/user/home',
|
||||||
|
icon: <Icon as={MdDashboard} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: <common.T text="profile" />,
|
||||||
|
path: '/user/profile',
|
||||||
|
icon: <Icon as={MdPerson} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default items;
|
10
src/config/translations/auth.ts
Normal file
10
src/config/translations/auth.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { provider } from './provider';
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export const auth = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
login: 'Sign in',
|
||||||
|
'login description': 'Login and start using our bot today',
|
||||||
|
login_bn: 'Login with Discord',
|
||||||
|
},
|
||||||
|
});
|
16
src/config/translations/common.ts
Normal file
16
src/config/translations/common.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { provider } from './provider';
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export const common = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
loading: 'Loading',
|
||||||
|
search: 'Search',
|
||||||
|
'select lang': 'Select your language',
|
||||||
|
'select role': 'Select a role',
|
||||||
|
'select channel': 'Select a channel',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
profile: 'Profile',
|
||||||
|
pages: 'Pages',
|
||||||
|
logout: 'Logout',
|
||||||
|
},
|
||||||
|
});
|
26
src/config/translations/dashboard.ts
Normal file
26
src/config/translations/dashboard.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { provider } from './provider';
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export const dashboard = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
pricing: 'Pricing',
|
||||||
|
learn_more: 'Learn More',
|
||||||
|
invite: {
|
||||||
|
title: 'Invite our Bot',
|
||||||
|
description: 'Try our discord bot with one-click',
|
||||||
|
bn: 'Invite now',
|
||||||
|
},
|
||||||
|
servers: {
|
||||||
|
title: 'Select Server',
|
||||||
|
description: 'Select the server to configure',
|
||||||
|
},
|
||||||
|
vc: {
|
||||||
|
create: 'Create a voice channel',
|
||||||
|
'created channels': 'Created Voice channels',
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
title: 'Command Usage',
|
||||||
|
description: 'Use of commands of your server',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
20
src/config/translations/feature.ts
Normal file
20
src/config/translations/feature.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { provider } from './provider';
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export const feature = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
unsaved: 'Save Changes',
|
||||||
|
error: {
|
||||||
|
'not enabled': 'Not Enabled',
|
||||||
|
'not enabled description': 'Try enable this feature?',
|
||||||
|
'not found': 'Not Found',
|
||||||
|
'not found description': "Hmm... Weird we can't find it",
|
||||||
|
},
|
||||||
|
bn: {
|
||||||
|
enable: 'Enable Feature',
|
||||||
|
disable: 'Disable',
|
||||||
|
save: 'Save',
|
||||||
|
discard: 'Discard',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
23
src/config/translations/guild.ts
Normal file
23
src/config/translations/guild.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { provider } from './provider';
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export const guild = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
features: 'Features',
|
||||||
|
banner: {
|
||||||
|
title: 'Getting Started',
|
||||||
|
description: 'Create your bot and type something',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
'not found': 'Where is it?',
|
||||||
|
'not found description': "The bot can't access the server, let's invite him!",
|
||||||
|
load: 'Failed to load guild',
|
||||||
|
},
|
||||||
|
bn: {
|
||||||
|
'enable feature': 'Enable',
|
||||||
|
'config feature': 'Config',
|
||||||
|
invite: 'Invite bot',
|
||||||
|
settings: 'Settings',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
16
src/config/translations/profile.ts
Normal file
16
src/config/translations/profile.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { createI18n } from '@/utils/i18n';
|
||||||
|
import { common } from './common';
|
||||||
|
import { provider } from './provider';
|
||||||
|
|
||||||
|
export const profile = createI18n(provider, {
|
||||||
|
en: {
|
||||||
|
logout: common.translations.en.logout,
|
||||||
|
language: 'Language',
|
||||||
|
'language description': 'Select your language',
|
||||||
|
settings: 'Settings',
|
||||||
|
'dark mode': 'Dark Mode',
|
||||||
|
'dark mode description': 'Enables dark theme in order to protect your eyes',
|
||||||
|
'dev mode': 'Developer Mode',
|
||||||
|
'dev mode description': 'Used for debugging and testing',
|
||||||
|
},
|
||||||
|
});
|
29
src/config/translations/provider.ts
Normal file
29
src/config/translations/provider.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { initLanguages, initI18n } from '@/utils/i18n';
|
||||||
|
import Router, { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported languages
|
||||||
|
*/
|
||||||
|
export type Languages = 'en';
|
||||||
|
export const { languages, names } = initLanguages<Languages>({
|
||||||
|
en: 'English',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const provider = initI18n<Languages>({
|
||||||
|
useLang: () => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (router.locale as Languages) ?? 'en';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useLang() {
|
||||||
|
const lang = provider.useLang();
|
||||||
|
return {
|
||||||
|
lang,
|
||||||
|
setLang(lang: Languages) {
|
||||||
|
const path = Router.asPath;
|
||||||
|
|
||||||
|
Router.push(path, path, { locale: lang });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
32
src/config/types/custom-types.ts
Normal file
32
src/config/types/custom-types.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
/***
|
||||||
|
* Custom types that should be configured by developer
|
||||||
|
***/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { GuildInfo } from './types';
|
||||||
|
|
||||||
|
export type CustomGuildInfo = GuildInfo & {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define feature ids and it's option types
|
||||||
|
*/
|
||||||
|
export type CustomFeatures = {
|
||||||
|
music: {};
|
||||||
|
gaming: {};
|
||||||
|
'reaction-role': {};
|
||||||
|
meme: {};
|
||||||
|
'welcome-message': WelcomeMessageFeature;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** example only */
|
||||||
|
export type WelcomeMessageFeature = {
|
||||||
|
channel?: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const memeFeatureSchema = z.object({
|
||||||
|
channel: z.string().optional(),
|
||||||
|
source: z.enum(['youtube', 'twitter', 'discord']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MemeFeature = z.infer<typeof memeFeatureSchema>;
|
2
src/config/types/index.ts
Normal file
2
src/config/types/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './custom-types';
|
||||||
|
export * from './types';
|
85
src/config/types/types.ts
Normal file
85
src/config/types/types.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { CustomFeatures } from './custom-types';
|
||||||
|
import { Guild } from '@/api/discord';
|
||||||
|
import { ReactElement, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
/**
|
||||||
|
* bot name
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Image Url
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
/**
|
||||||
|
* Guild settings
|
||||||
|
*/
|
||||||
|
guild: GuildConfig;
|
||||||
|
/**
|
||||||
|
* Url to invite the bot
|
||||||
|
*
|
||||||
|
* example: `https://discord.com/api/oauth2/authorize?client_id=907955781972918281&permissions=8&scope=bot`
|
||||||
|
*/
|
||||||
|
inviteUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuildConfig = {
|
||||||
|
/**
|
||||||
|
* Filter configurable guilds
|
||||||
|
*
|
||||||
|
* ex: to allow only if user permissions include ADMINISTRATOR
|
||||||
|
* ```
|
||||||
|
* import { PermissionFlags } from '@/api/discord';
|
||||||
|
* (Number(guild.permissions) & PermissionFlags.ADMINISTRATOR) !== 0
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
filter: (guild: Guild) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GuildInfo {
|
||||||
|
enabledFeatures: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeaturesConfig = {
|
||||||
|
[K in keyof CustomFeatures]: FeatureConfig<K>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal Feature info
|
||||||
|
*/
|
||||||
|
export interface FeatureConfig<K extends keyof CustomFeatures> {
|
||||||
|
name: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
icon?: ReactElement;
|
||||||
|
/**
|
||||||
|
* Render content in Feature view
|
||||||
|
*/
|
||||||
|
useRender: UseFormRender<CustomFeatures[K]>;
|
||||||
|
/**
|
||||||
|
* Render skeleton before featrue is loaded
|
||||||
|
*/
|
||||||
|
useSkeleton?: () => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitFn<T> = (data: FormData | string) => Promise<T>;
|
||||||
|
|
||||||
|
export type UseFormRenderResult = {
|
||||||
|
/**
|
||||||
|
* Save bar will be disappeared if `canSave` is false
|
||||||
|
*/
|
||||||
|
canSave?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* called on submit
|
||||||
|
*/
|
||||||
|
onSubmit: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset current value
|
||||||
|
*/
|
||||||
|
reset?: () => void;
|
||||||
|
|
||||||
|
component: ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseFormRender<T = unknown> = (data: T, onSubmit: SubmitFn<T>) => UseFormRenderResult;
|
6
src/middleware.ts
Normal file
6
src/middleware.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export { default } from './utils/auth/middleware';
|
||||||
|
|
||||||
|
//Pages need to be logged in before visiting
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/guilds/:path*', '/user/:path*'],
|
||||||
|
};
|
35
src/pages/_app.tsx
Normal file
35
src/pages/_app.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { ChakraProvider } from '@chakra-ui/react';
|
||||||
|
import { AppProps } from 'next/app';
|
||||||
|
import { theme } from '@/theme/config';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { client } from '@/api/hooks';
|
||||||
|
import { NextPage } from 'next';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
import '@/styles/global.css';
|
||||||
|
import 'react-calendar/dist/Calendar.css';
|
||||||
|
import '@/styles/date-picker.css';
|
||||||
|
|
||||||
|
export type NextPageWithLayout = NextPage & {
|
||||||
|
getLayout?: (children: ReactNode) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppPropsWithLayout = AppProps & {
|
||||||
|
Component: NextPageWithLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppPropsWithLayout) {
|
||||||
|
const getLayout = Component.getLayout ?? ((c) => c);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraProvider theme={theme}>
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<Head>
|
||||||
|
<title>Demo Bot</title>
|
||||||
|
</Head>
|
||||||
|
{getLayout(<Component {...pageProps} />)}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ChakraProvider>
|
||||||
|
);
|
||||||
|
}
|
18
src/pages/_document.tsx
Normal file
18
src/pages/_document.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// pages/_document.js
|
||||||
|
|
||||||
|
import { ColorModeScript } from '@chakra-ui/react';
|
||||||
|
import { Html, Head, Main, NextScript } from 'next/document';
|
||||||
|
import { theme } from '@/theme/config';
|
||||||
|
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<Html lang="en">
|
||||||
|
<Head />
|
||||||
|
<body>
|
||||||
|
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
64
src/pages/api/auth/callback.ts
Normal file
64
src/pages/api/auth/callback.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import {
|
||||||
|
AccessToken,
|
||||||
|
API_ENDPOINT,
|
||||||
|
CLIENT_ID,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
setServerSession,
|
||||||
|
} from '@/utils/auth/server';
|
||||||
|
import { i18n } from 'next.config';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getAbsoluteUrl } from '@/utils/get-absolute-url';
|
||||||
|
|
||||||
|
async function exchangeToken(code: string): Promise<AccessToken> {
|
||||||
|
const data = {
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code,
|
||||||
|
redirect_uri: `${getAbsoluteUrl()}/api/auth/callback`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_ENDPOINT}/oauth2/token`, {
|
||||||
|
headers,
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return (await response.json()) as AccessToken;
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to exchange token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
state: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
//Handle unsupported locales
|
||||||
|
.transform((v) => {
|
||||||
|
if (i18n == null || v == null) return undefined;
|
||||||
|
|
||||||
|
return i18n.locales.find((locale) => locale === v);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const query = querySchema.safeParse(req.query);
|
||||||
|
|
||||||
|
if (!query.success) {
|
||||||
|
return res.status(400).json('Invalid query param');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, state } = query.data;
|
||||||
|
const token = await exchangeToken(code);
|
||||||
|
|
||||||
|
setServerSession(req, res, token);
|
||||||
|
res.redirect(state ? `/${state}/user/home` : `/user/home`);
|
||||||
|
}
|
13
src/pages/api/auth/index.ts
Normal file
13
src/pages/api/auth/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { getServerSession } from '@/utils/auth/server';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const session = getServerSession(req);
|
||||||
|
|
||||||
|
if (!session.success) {
|
||||||
|
res.status(401).json('You must login first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(session.data);
|
||||||
|
}
|
21
src/pages/api/auth/login.ts
Normal file
21
src/pages/api/auth/login.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { CLIENT_ID } from '@/utils/auth/server';
|
||||||
|
import { getAbsoluteUrl } from '@/utils/get-absolute-url';
|
||||||
|
|
||||||
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { locale } = req.query as {
|
||||||
|
locale?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const url =
|
||||||
|
'https://discord.com/api/oauth2/authorize?' +
|
||||||
|
new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: `${getAbsoluteUrl()}/api/auth/callback`,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'identify guilds',
|
||||||
|
state: locale ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(302, url);
|
||||||
|
}
|
7
src/pages/api/auth/signout.ts
Normal file
7
src/pages/api/auth/signout.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { removeSession } from '@/utils/auth/server';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
await removeSession(req, res);
|
||||||
|
res.status(200).json('logged out');
|
||||||
|
}
|
66
src/pages/auth/signin.tsx
Normal file
66
src/pages/auth/signin.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { Button, Flex, Heading, Icon, Text } from '@chakra-ui/react';
|
||||||
|
import { BsDiscord } from 'react-icons/bs';
|
||||||
|
import { auth } from '@/config/translations/auth';
|
||||||
|
import { NextPageWithLayout } from '@/pages/_app';
|
||||||
|
import AuthLayout from '@/components/layout/auth';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { GetServerSideProps } from 'next';
|
||||||
|
import { getServerSession } from '@/utils/auth/server';
|
||||||
|
|
||||||
|
const LoginPage: NextPageWithLayout = () => {
|
||||||
|
const t = auth.useTranslations();
|
||||||
|
const locale = useRouter().locale;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
w="full"
|
||||||
|
h="full"
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
textAlign="center"
|
||||||
|
gap={3}
|
||||||
|
>
|
||||||
|
<Heading size="xl" whiteSpace="pre-wrap" fontWeight="600">
|
||||||
|
{t.login}
|
||||||
|
</Heading>
|
||||||
|
<Text color="TextSecondary" fontSize="lg">
|
||||||
|
{t['login description']}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
mt={3}
|
||||||
|
leftIcon={<Icon as={BsDiscord} fontSize="2xl" />}
|
||||||
|
variant="action"
|
||||||
|
size="lg"
|
||||||
|
width="350px"
|
||||||
|
maxW="full"
|
||||||
|
as="a"
|
||||||
|
href={`/api/auth/login?locale=${locale}`}
|
||||||
|
>
|
||||||
|
{t.login_bn}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
LoginPage.getLayout = (c) => <AuthLayout>{c}</AuthLayout>;
|
||||||
|
export default LoginPage;
|
||||||
|
|
||||||
|
//Redirect the user back to home if they have been logged in
|
||||||
|
export const getServerSideProps: GetServerSideProps<{}> = async ({ req }) => {
|
||||||
|
const loggedin = getServerSession(req).success;
|
||||||
|
|
||||||
|
if (loggedin) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: '/user/home',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
};
|
72
src/pages/guilds/[guild]/features/[feature].tsx
Normal file
72
src/pages/guilds/[guild]/features/[feature].tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { Icon } from '@chakra-ui/react';
|
||||||
|
import { Center, Heading, Text } from '@chakra-ui/layout';
|
||||||
|
import { Button } from '@chakra-ui/react';
|
||||||
|
import { LoadingPanel } from '@/components/panel/LoadingPanel';
|
||||||
|
import { features } from '@/config/features';
|
||||||
|
import { CustomFeatures, FeatureConfig } from '@/config/types';
|
||||||
|
import { BsSearch } from 'react-icons/bs';
|
||||||
|
import { useEnableFeatureMutation, useFeatureQuery } from '@/api/hooks';
|
||||||
|
import { UpdateFeaturePanel } from '@/components/feature/UpdateFeaturePanel';
|
||||||
|
import { feature as view } from '@/config/translations/feature';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { NextPageWithLayout } from '@/pages/_app';
|
||||||
|
import getGuildLayout from '@/components/layout/guild/get-guild-layout';
|
||||||
|
|
||||||
|
export type Params = {
|
||||||
|
guild: string;
|
||||||
|
feature: keyof CustomFeatures;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateFeatureValue<K extends keyof CustomFeatures> = Partial<CustomFeatures[K]>;
|
||||||
|
|
||||||
|
const FeaturePage: NextPageWithLayout = () => {
|
||||||
|
const { feature, guild } = useRouter().query as Params;
|
||||||
|
|
||||||
|
const query = useFeatureQuery(guild, feature);
|
||||||
|
const featureConfig = features[feature] as FeatureConfig<typeof feature>;
|
||||||
|
const skeleton = featureConfig?.useSkeleton?.();
|
||||||
|
|
||||||
|
if (featureConfig == null) return <NotFound />;
|
||||||
|
if (query.isError) return <NotEnabled />;
|
||||||
|
if (query.isLoading) return skeleton != null ? <>{skeleton}</> : <LoadingPanel />;
|
||||||
|
return <UpdateFeaturePanel key={feature} feature={query.data} config={featureConfig} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotEnabled() {
|
||||||
|
const t = view.useTranslations();
|
||||||
|
const { guild, feature } = useRouter().query as Params;
|
||||||
|
const enable = useEnableFeatureMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center flexDirection="column" h="full" gap={1}>
|
||||||
|
<Text fontSize="xl" fontWeight="600">
|
||||||
|
{t.error['not enabled']}
|
||||||
|
</Text>
|
||||||
|
<Text color="TextSecondary">{t.error['not enabled description']}</Text>
|
||||||
|
<Button
|
||||||
|
mt={3}
|
||||||
|
isLoading={enable.isPending}
|
||||||
|
onClick={() => enable.mutate({ enabled: true, guild, feature })}
|
||||||
|
variant="action"
|
||||||
|
px={6}
|
||||||
|
>
|
||||||
|
{t.bn.enable}
|
||||||
|
</Button>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotFound() {
|
||||||
|
const t = view.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center flexDirection="column" gap={2} h="full">
|
||||||
|
<Icon as={BsSearch} w="50px" h="50px" />
|
||||||
|
<Heading size="lg">{t.error['not found']}</Heading>
|
||||||
|
<Text color="TextSecondary">{t.error['not found description']}</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FeaturePage.getLayout = (c) => getGuildLayout({ children: c, back: true });
|
||||||
|
export default FeaturePage;
|
83
src/pages/guilds/[guild]/index.tsx
Normal file
83
src/pages/guilds/[guild]/index.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { Center, Flex, Heading, SimpleGrid, Text, Button, Icon } from '@chakra-ui/react';
|
||||||
|
import { LoadingPanel } from '@/components/panel/LoadingPanel';
|
||||||
|
import { QueryStatus } from '@/components/panel/QueryPanel';
|
||||||
|
import { config } from '@/config/common';
|
||||||
|
import { guild as view } from '@/config/translations/guild';
|
||||||
|
import { BsMailbox } from 'react-icons/bs';
|
||||||
|
import { FaRobot } from 'react-icons/fa';
|
||||||
|
import { useGuildInfoQuery } from '@/api/hooks';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { getFeatures } from '@/utils/common';
|
||||||
|
import { Banner } from '@/components/GuildBanner';
|
||||||
|
import { FeatureItem } from '@/components/feature/FeatureItem';
|
||||||
|
import type { CustomGuildInfo } from '@/config/types/custom-types';
|
||||||
|
import { NextPageWithLayout } from '@/pages/_app';
|
||||||
|
import getGuildLayout from '@/components/layout/guild/get-guild-layout';
|
||||||
|
|
||||||
|
const GuildPage: NextPageWithLayout = () => {
|
||||||
|
const t = view.useTranslations();
|
||||||
|
const guild = useRouter().query.guild as string;
|
||||||
|
const query = useGuildInfoQuery(guild);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryStatus query={query} loading={<LoadingPanel />} error={t.error.load}>
|
||||||
|
{query.data != null ? (
|
||||||
|
<GuildPanel guild={guild} info={query.data} />
|
||||||
|
) : (
|
||||||
|
<NotJoined guild={guild} />
|
||||||
|
)}
|
||||||
|
</QueryStatus>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function GuildPanel({ guild: id, info }: { guild: string; info: CustomGuildInfo }) {
|
||||||
|
const t = view.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap={5}>
|
||||||
|
<Banner />
|
||||||
|
<Flex direction="column" gap={5} mt={3}>
|
||||||
|
<Heading size="md">{t.features}</Heading>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, '2xl': 3 }} gap={3}>
|
||||||
|
{getFeatures().map((feature) => (
|
||||||
|
<FeatureItem
|
||||||
|
key={feature.id}
|
||||||
|
guild={id}
|
||||||
|
feature={feature}
|
||||||
|
enabled={info.enabledFeatures.includes(feature.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotJoined({ guild }: { guild: string }) {
|
||||||
|
const t = view.useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center flexDirection="column" gap={3} h="full" p={5}>
|
||||||
|
<Icon as={BsMailbox} w={50} h={50} />
|
||||||
|
<Text fontSize="xl" fontWeight="600">
|
||||||
|
{t.error['not found']}
|
||||||
|
</Text>
|
||||||
|
<Text textAlign="center" color="TextSecondary">
|
||||||
|
{t.error['not found description']}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="action"
|
||||||
|
leftIcon={<FaRobot />}
|
||||||
|
px={6}
|
||||||
|
as="a"
|
||||||
|
href={`${config.inviteUrl}&guild_id=${guild}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t.bn.invite}
|
||||||
|
</Button>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GuildPage.getLayout = (c) => getGuildLayout({ children: c });
|
||||||
|
export default GuildPage;
|
91
src/pages/guilds/[guild]/settings.tsx
Normal file
91
src/pages/guilds/[guild]/settings.tsx
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import { Box, Flex, Heading, SimpleGrid, Text } from '@chakra-ui/layout';
|
||||||
|
import { RoleSelectForm } from '@/components/forms/RoleSelect';
|
||||||
|
import getGuildLayout from '@/components/layout/guild/get-guild-layout';
|
||||||
|
import { NextPageWithLayout } from '@/pages/_app';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { SwitchFieldForm } from '@/components/forms/SwitchField';
|
||||||
|
import { InputForm } from '@/components/forms/InputForm';
|
||||||
|
import { ChannelSelectForm } from '@/components/forms/ChannelSelect';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
beta: z.boolean(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
prefix: z.string().min(1).max(1),
|
||||||
|
channel: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ExampleSettings = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exmaple for using react-hook-form with built-in components
|
||||||
|
*/
|
||||||
|
const GuildSettingsPage: NextPageWithLayout = () => {
|
||||||
|
const { watch, register, control, formState, handleSubmit } = useForm<ExampleSettings>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
beta: true,
|
||||||
|
prefix: '/',
|
||||||
|
role: undefined,
|
||||||
|
channel: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const errors = formState.errors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column">
|
||||||
|
<Box ml={{ '3sm': 5 }}>
|
||||||
|
<Heading fontSize="2xl" fontWeight="600">
|
||||||
|
Guild Settings
|
||||||
|
</Heading>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="500"
|
||||||
|
as="code"
|
||||||
|
_light={{ color: 'cyan.500' }}
|
||||||
|
_dark={{ color: 'cyan.400' }}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={handleSubmit(() => console.log('submit'))}
|
||||||
|
>
|
||||||
|
{JSON.stringify(watch())}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<SimpleGrid mt={5} columns={{ base: 1, md: 2 }} gap={3}>
|
||||||
|
<SwitchFieldForm
|
||||||
|
control={{
|
||||||
|
label: 'Beta Features',
|
||||||
|
description: 'Use beta features before releasing',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'beta' }}
|
||||||
|
/>
|
||||||
|
<RoleSelectForm
|
||||||
|
control={{
|
||||||
|
label: 'Admin Role',
|
||||||
|
description: 'Roles that able to configure the discord bot',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'role' }}
|
||||||
|
/>
|
||||||
|
<InputForm
|
||||||
|
control={{
|
||||||
|
label: 'Command prefix',
|
||||||
|
description: 'Change the default command prefix',
|
||||||
|
error: errors.prefix?.message,
|
||||||
|
}}
|
||||||
|
placeholder="/"
|
||||||
|
{...register('prefix')}
|
||||||
|
/>
|
||||||
|
<ChannelSelectForm
|
||||||
|
control={{
|
||||||
|
label: 'Logs',
|
||||||
|
description: 'The channel to log bot states',
|
||||||
|
}}
|
||||||
|
controller={{ control, name: 'channel' }}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
GuildSettingsPage.getLayout = (c) => getGuildLayout({ children: c, back: true });
|
||||||
|
export default GuildSettingsPage;
|
68
src/pages/user/home.tsx
Normal file
68
src/pages/user/home.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import {
|
||||||
|
Heading,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
Avatar,
|
||||||
|
Flex,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { config } from '@/config/common';
|
||||||
|
import { useGuilds } from '@/api/hooks';
|
||||||
|
import HomeView from '@/config/example/HomeView';
|
||||||
|
import { NextPageWithLayout } from '@/pages/_app';
|
||||||
|
import AppLayout from '@/components/layout/app';
|
||||||
|
import { iconUrl } from '@/api/discord';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const HomePage: NextPageWithLayout = () => {
|
||||||
|
//used for example only, you should remove it
|
||||||
|
return <HomeView />;
|
||||||
|
|
||||||
|
return <GuildSelect />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GuildSelect() {
|
||||||
|
const guilds = useGuilds();
|
||||||
|
|
||||||
|
if (guilds.status === 'success')
|
||||||
|
return (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, xl: 3 }} gap={3}>
|
||||||
|
{guilds.data
|
||||||
|
?.filter((guild) => config.guild.filter(guild))
|
||||||
|
.map((guild) => (
|
||||||
|
<Card key={guild.id} variant="primary" as={Link} href={`/guilds/${guild.id}`}>
|
||||||
|
<CardHeader as={Flex} flexDirection="row" gap={3}>
|
||||||
|
<Avatar src={iconUrl(guild)} name={guild.name} size="md" />
|
||||||
|
<Text>{guild.name}</Text>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (guilds.status === 'error')
|
||||||
|
return (
|
||||||
|
<Button w="fit-content" variant="danger" onClick={() => guilds.refetch()}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (guilds.status === 'pending')
|
||||||
|
return (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, xl: 3 }} gap={3}>
|
||||||
|
<Skeleton minH="88px" rounded="2xl" />
|
||||||
|
<Skeleton minH="88px" rounded="2xl" />
|
||||||
|
<Skeleton minH="88px" rounded="2xl" />
|
||||||
|
<Skeleton minH="88px" rounded="2xl" />
|
||||||
|
<Skeleton minH="88px" rounded="2xl" />
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
HomePage.getLayout = (c) => <AppLayout>{c}</AppLayout>;
|
||||||
|
export default HomePage;
|
126
src/pages/user/profile.tsx
Normal file
126
src/pages/user/profile.tsx
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import { Flex, Grid, Spacer, Text, VStack } from '@chakra-ui/layout';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Image,
|
||||||
|
useColorMode,
|
||||||
|
Box,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { avatarUrl, bannerUrl } from '@/api/discord';
|
||||||
|
import { SelectField } from '@/components/forms/SelectField';
|
||||||
|
import { SwitchField } from '@/components/forms/SwitchField';
|
||||||
|
import { languages, names, useLang } from '@/config/translations/provider';
|
||||||
|
import { profile } from '@/config/translations/profile';
|
||||||
|
import { IoLogOut } from 'react-icons/io5';
|
||||||
|
import { useSettingsStore } from '@/stores';
|
||||||
|
import { NextPageWithLayout } from '@/pages/_app';
|
||||||
|
import AppLayout from '@/components/layout/app';
|
||||||
|
import { useLogoutMutation } from '@/utils/auth/hooks';
|
||||||
|
import { useSelfUser } from '@/api/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User info and general settings here
|
||||||
|
*/
|
||||||
|
const ProfilePage: NextPageWithLayout = () => {
|
||||||
|
const user = useSelfUser();
|
||||||
|
const logout = useLogoutMutation();
|
||||||
|
const t = profile.useTranslations();
|
||||||
|
|
||||||
|
const { colorMode, setColorMode } = useColorMode();
|
||||||
|
const { lang, setLang } = useLang();
|
||||||
|
const [devMode, setDevMode] = useSettingsStore((s) => [s.devMode, s.setDevMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid templateColumns={{ base: '1fr', lg: 'minmax(0, 800px) auto' }} gap={{ base: 3, lg: 6 }}>
|
||||||
|
<Flex direction="column">
|
||||||
|
{user.banner != null ? (
|
||||||
|
<Image
|
||||||
|
alt="banner"
|
||||||
|
src={bannerUrl(user.id, user.banner)}
|
||||||
|
sx={{ aspectRatio: '1100 / 440' }}
|
||||||
|
objectFit="cover"
|
||||||
|
rounded="2xl"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box bg="Brand" rounded="2xl" sx={{ aspectRatio: '1100 / 440' }} />
|
||||||
|
)}
|
||||||
|
<VStack mt="-50px" ml="40px" align="start">
|
||||||
|
<Avatar
|
||||||
|
src={avatarUrl(user)}
|
||||||
|
name={user.username}
|
||||||
|
w="100px"
|
||||||
|
h="100px"
|
||||||
|
ringColor="CardBackground"
|
||||||
|
ring="6px"
|
||||||
|
/>
|
||||||
|
<Text fontWeight="600" fontSize="2xl">
|
||||||
|
{user.username}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
<Card w="full" rounded="3xl" h="fit-content" variant="primary">
|
||||||
|
<CardHeader fontSize="2xl" fontWeight="600">
|
||||||
|
{t.settings}
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody as={Flex} direction="column" gap={6} mt={3}>
|
||||||
|
<SwitchField
|
||||||
|
id="dark-mode"
|
||||||
|
label={t['dark mode']}
|
||||||
|
desc={t['dark mode description']}
|
||||||
|
isChecked={colorMode === 'dark'}
|
||||||
|
onChange={(e) => setColorMode(e.target.checked ? 'dark' : 'light')}
|
||||||
|
/>
|
||||||
|
<SwitchField
|
||||||
|
id="developer-mode"
|
||||||
|
label={t['dev mode']}
|
||||||
|
desc={t['dev mode description']}
|
||||||
|
isChecked={devMode}
|
||||||
|
onChange={(e) => setDevMode(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<FormControl>
|
||||||
|
<Box mb={2}>
|
||||||
|
<FormLabel fontSize="md" fontWeight="medium" m={0}>
|
||||||
|
{t.language}
|
||||||
|
</FormLabel>
|
||||||
|
<Text color="TextSecondary">{t['language description']}</Text>
|
||||||
|
</Box>
|
||||||
|
<SelectField
|
||||||
|
value={{
|
||||||
|
label: names[lang],
|
||||||
|
value: lang,
|
||||||
|
}}
|
||||||
|
onChange={(e) => e != null && setLang(e.value)}
|
||||||
|
options={languages.map((lang) => ({
|
||||||
|
label: lang.name,
|
||||||
|
value: lang.key,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Spacer />
|
||||||
|
<Button
|
||||||
|
leftIcon={<IoLogOut />}
|
||||||
|
variant="danger"
|
||||||
|
isLoading={logout.isPending}
|
||||||
|
onClick={() => logout.mutate()}
|
||||||
|
>
|
||||||
|
{t.logout}
|
||||||
|
</Button>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Content />
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfilePage.getLayout = (p) => <AppLayout>{p}</AppLayout>;
|
||||||
|
|
||||||
|
export default ProfilePage;
|
1
src/stores/index.ts
Normal file
1
src/stores/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './pageStore';
|
32
src/stores/pageStore.ts
Normal file
32
src/stores/pageStore.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export type PageStore = {
|
||||||
|
sidebarIsOpen: boolean;
|
||||||
|
setSidebarIsOpen: (v: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PersistStore = {
|
||||||
|
devMode: boolean;
|
||||||
|
setDevMode: (v: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePageStore = create<PageStore>((set) => ({
|
||||||
|
sidebarIsOpen: false,
|
||||||
|
setSidebarIsOpen: (v) => set({ sidebarIsOpen: v }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* persist settings
|
||||||
|
*/
|
||||||
|
export const useSettingsStore = create(
|
||||||
|
persist<PersistStore>(
|
||||||
|
(set) => ({
|
||||||
|
devMode: false,
|
||||||
|
setDevMode: (v) => set({ devMode: v }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'settings',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
164
src/styles/date-picker.css
Normal file
164
src/styles/date-picker.css
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
.react-calendar {
|
||||||
|
width: 100%;
|
||||||
|
border: unset;
|
||||||
|
background-color: transparent;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
.react-calendar__navigation__prev2-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.react-calendar__navigation__next2-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.react-calendar__navigation {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
abbr[title] {
|
||||||
|
border-bottom: none;
|
||||||
|
-webkit-text-decoration: unset;
|
||||||
|
text-decoration: unset;
|
||||||
|
-webkit-text-decoration: unset;
|
||||||
|
-webkit-text-decoration: unset;
|
||||||
|
text-decoration: unset !important;
|
||||||
|
}
|
||||||
|
.react-calendar__navigation__prev-button {
|
||||||
|
background-color: #4318ff !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 34px !important;
|
||||||
|
height: 34px !important;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.react-calendar__navigation__next-button {
|
||||||
|
background-color: #4318ff !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 34px !important;
|
||||||
|
width: 34px !important;
|
||||||
|
height: 34px !important;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.react-calendar__navigation__label {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.react-calendar__navigation__label:hover,
|
||||||
|
.react-calendar__navigation__label:focus {
|
||||||
|
background-color: unset !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.react-calendar__tile {
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0px !important;
|
||||||
|
height: 34px !important;
|
||||||
|
color: #1b2559;
|
||||||
|
}
|
||||||
|
.react-calendar__month-view__weekdays {
|
||||||
|
background-color: #f4f7fe;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.react-calendar__tile--now,
|
||||||
|
.react-calendar__tile--now:enabled:hover,
|
||||||
|
.react-calendar__tile--now:enabled:focus {
|
||||||
|
background-color: #f4f7fe;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.react-calendar__month-view__days__day--neighboringMonth {
|
||||||
|
color: #b0bbd5;
|
||||||
|
}
|
||||||
|
.react-calendar__tile--active,
|
||||||
|
.react-calendar__tile--active:enabled:hover,
|
||||||
|
.react-calendar__tile--active:enabled:focus {
|
||||||
|
background: #4318ff;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.react-calendar__tile--range,
|
||||||
|
.react-calendar__tile--range:enabled:hover,
|
||||||
|
.react-calendar__tile--range:enabled:focus {
|
||||||
|
background: #f4f7fe;
|
||||||
|
color: #4318ff;
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
|
.react-calendar__tile--rangeStart,
|
||||||
|
.react-calendar__tile--rangeStart:enabled:hover,
|
||||||
|
.react-calendar__tile--rangeStart:enabled:focus {
|
||||||
|
background: #4318ff;
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.react-calendar__tile--rangeEnd,
|
||||||
|
.react-calendar__tile--rangeEnd:enabled:hover,
|
||||||
|
.react-calendar__tile--rangeEnd:enabled:focus {
|
||||||
|
background: #4318ff;
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.react-calendar__tile:disabled {
|
||||||
|
color: #b0bbd5;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DARK MODE */
|
||||||
|
|
||||||
|
body.chakra-ui-dark .react-calendar {
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__navigation__prev-button {
|
||||||
|
background-color: #7551ff !important;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__navigation__next-button {
|
||||||
|
background-color: #7551ff !important;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__tile {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__tile:enabled:hover,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile:enabled:focus {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__month-view__weekdays {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--now,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--now:enabled:hover,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--now:enabled:focus {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__month-view__days__day--neighboringMonth {
|
||||||
|
color: #b0bbd5;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--active,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--active:enabled:hover,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--active:enabled:focus {
|
||||||
|
background: #7551ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--range,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--range:enabled:hover,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--range:enabled:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #7551ff;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--rangeStart,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--rangeStart:enabled:hover,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--rangeStart:enabled:focus {
|
||||||
|
background: #7551ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--rangeEnd,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--rangeEnd:enabled:hover,
|
||||||
|
body.chakra-ui-dark .react-calendar__tile--rangeEnd:enabled:focus {
|
||||||
|
background: #7551ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chakra-ui-dark .react-calendar__tile:disabled {
|
||||||
|
color: rgba(176, 187, 213, 0.42);
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
19
src/styles/global.css
Normal file
19
src/styles/global.css
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', 'Noto Color Emoji' !important;
|
||||||
|
min-width: 350px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root,
|
||||||
|
#__next {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
option {
|
||||||
|
color: black;
|
||||||
|
}
|
23
src/styles/global.ts
Normal file
23
src/styles/global.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { mode } from '@chakra-ui/theme-tools';
|
||||||
|
|
||||||
|
export const globalStyles = (props: any) => ({
|
||||||
|
'::-webkit-scrollbar': {
|
||||||
|
w: '5px',
|
||||||
|
h: '5px',
|
||||||
|
bg: 'transparent',
|
||||||
|
},
|
||||||
|
'::-webkit-scrollbar-thumb': {
|
||||||
|
borderRadius: '10px',
|
||||||
|
bg: mode('rgba(0, 0, 0, 0.2)', 'rgba(255, 255, 255, 0.2)')(props),
|
||||||
|
},
|
||||||
|
'::-webkit-calendar-picker-indicator': {
|
||||||
|
filter: mode('none', 'invert(1)')(props),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
color: 'TextPrimary',
|
||||||
|
bg: 'MainBackground',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
color: 'gray.700',
|
||||||
|
},
|
||||||
|
});
|
13
src/theme/breakpoints.ts
Normal file
13
src/theme/breakpoints.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export const breakpoints = {
|
||||||
|
sm: '320px',
|
||||||
|
'2sm': '380px',
|
||||||
|
'3sm': '574px',
|
||||||
|
md: '768px',
|
||||||
|
lg: '960px',
|
||||||
|
xl: '1200px',
|
||||||
|
'2xl': '1600px',
|
||||||
|
'3xl': '1920px',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navbarBreakpoint = '3sm';
|
||||||
|
export const sidebarBreakpoint = 'xl';
|
77
src/theme/colors.ts
Normal file
77
src/theme/colors.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
export const colors = {
|
||||||
|
brand: {
|
||||||
|
100: '#E9E3FF',
|
||||||
|
200: '#9e86ff',
|
||||||
|
300: '#422AFB',
|
||||||
|
400: '#7551FF',
|
||||||
|
500: '#422AFB',
|
||||||
|
600: '#3311DB',
|
||||||
|
700: '#02044A',
|
||||||
|
800: '#190793',
|
||||||
|
900: '#11047A',
|
||||||
|
},
|
||||||
|
brandAlpha: {
|
||||||
|
500: '#7451ff9c',
|
||||||
|
100: '#7451ff2d',
|
||||||
|
},
|
||||||
|
secondaryGray: {
|
||||||
|
100: '#E0E5F2',
|
||||||
|
200: '#E1E9F8',
|
||||||
|
300: '#F4F7FE',
|
||||||
|
400: '#E9EDF7',
|
||||||
|
500: '#8F9BBA',
|
||||||
|
600: '#A3AED0',
|
||||||
|
700: '#707EAE',
|
||||||
|
800: '#707EAE',
|
||||||
|
900: '#1B2559',
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
500: '#EE5D50',
|
||||||
|
600: '#E31A1A',
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
50: '#EFF4FB',
|
||||||
|
500: '#3965FF',
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
100: '#FFF6DA',
|
||||||
|
500: '#FFB547',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
100: '#E6FAF5',
|
||||||
|
500: '#01B574',
|
||||||
|
},
|
||||||
|
navy: {
|
||||||
|
50: '#d0dcfb',
|
||||||
|
100: '#aac0fe',
|
||||||
|
200: '#a3b9f8',
|
||||||
|
300: '#728fea',
|
||||||
|
400: '#3652ba',
|
||||||
|
500: '#2f4bba',
|
||||||
|
600: '#232c4f',
|
||||||
|
700: '#1d2343',
|
||||||
|
800: '#080e2c',
|
||||||
|
900: '#08081c',
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
100: '#FAFCFE',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const light = {
|
||||||
|
globalBg: 'secondaryGray.300',
|
||||||
|
brand: 'brand.500',
|
||||||
|
textColorPrimary: 'secondaryGray.900',
|
||||||
|
textColorSecondary: 'gray.500',
|
||||||
|
cardBg: 'white',
|
||||||
|
shadow: '14px 17px 40px 4px rgba(112, 144, 176, 0.18)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dark = {
|
||||||
|
globalBg: 'navy.900',
|
||||||
|
brand: 'brand.400',
|
||||||
|
textColorPrimary: 'white',
|
||||||
|
textColorSecondary: 'gray.400',
|
||||||
|
cardBg: 'navy.800',
|
||||||
|
shadow: '14px 17px 40px 4px rgba(2, 4, 6, 0.06)',
|
||||||
|
};
|
32
src/theme/components/avatar.ts
Normal file
32
src/theme/components/avatar.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/react';
|
||||||
|
import { avatarAnatomy } from '@chakra-ui/anatomy';
|
||||||
|
|
||||||
|
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(
|
||||||
|
avatarAnatomy.keys
|
||||||
|
);
|
||||||
|
|
||||||
|
export const avatarStyles = defineMultiStyleConfig({
|
||||||
|
baseStyle: definePartsStyle({
|
||||||
|
container: {
|
||||||
|
bg: 'brand.300',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
variants: {
|
||||||
|
border: definePartsStyle({
|
||||||
|
container: {
|
||||||
|
border: 'auto',
|
||||||
|
borderWidth: 10,
|
||||||
|
borderColor: '#ffffff',
|
||||||
|
_dark: {
|
||||||
|
borderColor: 'navy.800',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
normal: definePartsStyle({
|
||||||
|
container: {
|
||||||
|
border: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
58
src/theme/components/button.ts
Normal file
58
src/theme/components/button.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { defineStyle, defineStyleConfig } from '@chakra-ui/react';
|
||||||
|
import { mode } from '@chakra-ui/theme-tools';
|
||||||
|
|
||||||
|
export const buttonStyles = defineStyleConfig({
|
||||||
|
baseStyle: defineStyle({
|
||||||
|
borderRadius: '16px',
|
||||||
|
transition: '.25s all ease',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
_focus: {
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
_active: {
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
variants: {
|
||||||
|
danger: defineStyle({
|
||||||
|
color: 'white',
|
||||||
|
bg: 'red.500',
|
||||||
|
_hover: { bg: 'red.400' },
|
||||||
|
_active: { bg: 'red.300' },
|
||||||
|
}),
|
||||||
|
action: defineStyle((props) => ({
|
||||||
|
fontWeight: '600',
|
||||||
|
borderRadius: '50px',
|
||||||
|
bg: mode(
|
||||||
|
'linear-gradient(to right bottom, var(--chakra-colors-brand-500), var(--chakra-colors-brand-400))',
|
||||||
|
'linear-gradient(to right bottom, var(--chakra-colors-brand-400), var(--chakra-colors-brand-500))'
|
||||||
|
)(props),
|
||||||
|
color: 'white',
|
||||||
|
rounded: 'xl',
|
||||||
|
boxShadow: mode(
|
||||||
|
'1px 2px 5px var(--chakra-colors-brand-400)',
|
||||||
|
'1px 2px 15px var(--chakra-colors-brand-400)'
|
||||||
|
)(props),
|
||||||
|
})),
|
||||||
|
secondary: defineStyle({
|
||||||
|
_light: {
|
||||||
|
bg: 'white',
|
||||||
|
shadow: 'normal',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: 'whiteAlpha.200',
|
||||||
|
_hover: {
|
||||||
|
bg: 'whiteAlpha.300',
|
||||||
|
},
|
||||||
|
_active: {
|
||||||
|
bg: 'whiteAlpha.300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
sm: {
|
||||||
|
px: '15px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
48
src/theme/components/card.ts
Normal file
48
src/theme/components/card.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { cardAnatomy } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
|
||||||
|
import { light, dark } from '@/theme/colors';
|
||||||
|
|
||||||
|
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(
|
||||||
|
cardAnatomy.keys
|
||||||
|
);
|
||||||
|
|
||||||
|
export const cardStyles = defineMultiStyleConfig({
|
||||||
|
baseStyle: definePartsStyle({
|
||||||
|
container: {
|
||||||
|
_light: {
|
||||||
|
'--custom-card-bg': `colors.${light.cardBg}`,
|
||||||
|
'--card-color': `colors.${light.textColorPrimary}`,
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
'--custom-card-bg': `colors.${dark.cardBg}`,
|
||||||
|
'--card-color': `colors.${dark.textColorPrimary}`,
|
||||||
|
},
|
||||||
|
color: 'var(--card-color)',
|
||||||
|
bg: 'var(--custom-card-bg)',
|
||||||
|
p: 'var(--card-padding)',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
fontSize: { base: '16px', md: 'lg' },
|
||||||
|
fontWeight: 'medium',
|
||||||
|
p: 0,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontSize: { base: 'sm', md: 'md' },
|
||||||
|
p: 0,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
p: 0,
|
||||||
|
mt: 4,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
variants: {
|
||||||
|
primary: definePartsStyle({
|
||||||
|
container: {
|
||||||
|
rounded: '2xl',
|
||||||
|
_light: {
|
||||||
|
boxShadow: '14px 17px 30px 4px rgb(112 144 176 / 10%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
129
src/theme/components/input.ts
Normal file
129
src/theme/components/input.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import { inputAnatomy } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/react';
|
||||||
|
import { dark, light } from '../colors';
|
||||||
|
|
||||||
|
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(
|
||||||
|
inputAnatomy.keys
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = definePartsStyle({
|
||||||
|
field: {
|
||||||
|
border: '2px solid',
|
||||||
|
borderRadius: '16px',
|
||||||
|
fontSize: 'sm',
|
||||||
|
p: '20px',
|
||||||
|
_light: {
|
||||||
|
color: 'secondaryGray.900',
|
||||||
|
bg: 'transparent',
|
||||||
|
_placeholder: {
|
||||||
|
color: 'secondaryGray.700',
|
||||||
|
},
|
||||||
|
_invalid: {
|
||||||
|
borderColor: 'red.400',
|
||||||
|
},
|
||||||
|
borderColor: 'secondaryGray.400',
|
||||||
|
},
|
||||||
|
|
||||||
|
_dark: {
|
||||||
|
color: 'white',
|
||||||
|
bg: 'navy.800',
|
||||||
|
_placeholder: {
|
||||||
|
color: 'secondaryGray.600',
|
||||||
|
},
|
||||||
|
_invalid: {
|
||||||
|
borderColor: 'red.400',
|
||||||
|
},
|
||||||
|
borderColor: 'navy.600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inputStyles = defineMultiStyleConfig({
|
||||||
|
baseStyle: definePartsStyle({
|
||||||
|
field: {
|
||||||
|
fontWeight: 400,
|
||||||
|
_light: {
|
||||||
|
borderColor: 'secondaryGray.400',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
borderColor: 'navy.600',
|
||||||
|
},
|
||||||
|
borderRadius: '8px',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
flushed: definePartsStyle({
|
||||||
|
field: {
|
||||||
|
_focus: {
|
||||||
|
_dark: {
|
||||||
|
borderColor: dark.brand,
|
||||||
|
},
|
||||||
|
_light: {
|
||||||
|
borderColor: light.brand,
|
||||||
|
},
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
|
||||||
|
fontSize: '2xl',
|
||||||
|
fontWeight: '600',
|
||||||
|
_light: {
|
||||||
|
color: light.textColorPrimary,
|
||||||
|
borderBottomColor: 'secondaryGray.400',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
color: dark.textColorPrimary,
|
||||||
|
borderBottomColor: 'navy.600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
main,
|
||||||
|
focus: definePartsStyle({
|
||||||
|
field: {
|
||||||
|
...main.field,
|
||||||
|
_focus: {
|
||||||
|
_light: {
|
||||||
|
borderColor: 'brand.300',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
borderColor: 'brand.400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
auth: definePartsStyle({
|
||||||
|
field: {
|
||||||
|
bg: 'transparent',
|
||||||
|
fontWeight: '500',
|
||||||
|
_light: {
|
||||||
|
color: 'navy.700',
|
||||||
|
borderColor: 'secondaryGray.100',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
color: 'white',
|
||||||
|
borderColor: 'rgba(135, 140, 189, 0.3)',
|
||||||
|
},
|
||||||
|
border: '1px solid',
|
||||||
|
borderRadius: '16px',
|
||||||
|
_placeholder: { color: 'secondaryGray.600', fontWeight: '400' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
authSecondary: definePartsStyle({
|
||||||
|
field: {
|
||||||
|
bg: 'transparent',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'secondaryGray.100',
|
||||||
|
borderRadius: '16px',
|
||||||
|
_placeholder: { color: 'secondaryGray.600' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
search: definePartsStyle({
|
||||||
|
field: {
|
||||||
|
border: 'none',
|
||||||
|
py: '11px',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
_placeholder: { color: 'secondaryGray.600' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
29
src/theme/components/menu.ts
Normal file
29
src/theme/components/menu.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { menuAnatomy as parts } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
|
||||||
|
import { light, dark } from '../colors';
|
||||||
|
|
||||||
|
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(parts.keys);
|
||||||
|
|
||||||
|
export const menuTheme = defineMultiStyleConfig({
|
||||||
|
baseStyle: definePartsStyle({
|
||||||
|
item: {
|
||||||
|
_hover: {
|
||||||
|
_light: {
|
||||||
|
bg: light.cardBg,
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: dark.cardBg,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bg: 'transparent',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
_light: {
|
||||||
|
bg: light.globalBg,
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: dark.globalBg,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
29
src/theme/components/modal.ts
Normal file
29
src/theme/components/modal.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { modalAnatomy as parts } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
|
||||||
|
|
||||||
|
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(parts.keys);
|
||||||
|
|
||||||
|
const baseStyle = definePartsStyle({
|
||||||
|
overlay: {
|
||||||
|
backdropFilter: 'auto',
|
||||||
|
backdropBlur: 'lg',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
_hover: {},
|
||||||
|
_focus: {
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
_light: {
|
||||||
|
bg: 'secondaryGray.300',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: 'navy.900',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const modalStyles = defineMultiStyleConfig({
|
||||||
|
baseStyle,
|
||||||
|
});
|
17
src/theme/components/popover.ts
Normal file
17
src/theme/components/popover.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { popoverAnatomy as parts } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
|
||||||
|
|
||||||
|
const { defineMultiStyleConfig, definePartsStyle } = createMultiStyleConfigHelpers(parts.keys);
|
||||||
|
|
||||||
|
export const popoverStyles = defineMultiStyleConfig({
|
||||||
|
baseStyle: definePartsStyle({
|
||||||
|
content: {
|
||||||
|
bg: 'secondaryGray.300',
|
||||||
|
rounded: 'xl',
|
||||||
|
boxShadow: 'normal',
|
||||||
|
_dark: {
|
||||||
|
bg: 'navy.900',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
49
src/theme/components/select.ts
Normal file
49
src/theme/components/select.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { dark } from './../colors';
|
||||||
|
import { selectAnatomy } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers, getToken, useToken } from '@chakra-ui/react';
|
||||||
|
import { light } from '@/theme/colors';
|
||||||
|
import { mode, StyleFunctionProps } from '@chakra-ui/theme-tools';
|
||||||
|
|
||||||
|
const { defineMultiStyleConfig, definePartsStyle } = createMultiStyleConfigHelpers(
|
||||||
|
selectAnatomy.keys
|
||||||
|
);
|
||||||
|
|
||||||
|
function getDefaults(props: StyleFunctionProps) {
|
||||||
|
const { focusBorderColor: fc, errorBorderColor: ec } = props;
|
||||||
|
return {
|
||||||
|
focusBorderColor: fc || mode(light.brand, dark.brand)(props),
|
||||||
|
errorBorderColor: ec || mode('red.500', 'red.300')(props),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outline = definePartsStyle((props) => {
|
||||||
|
const defaults = getDefaults(props);
|
||||||
|
const [focusBorderColor, errorBorderColor] = useToken('colors', [
|
||||||
|
defaults.focusBorderColor,
|
||||||
|
defaults.errorBorderColor,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
field: {
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'inherit',
|
||||||
|
bg: 'inherit',
|
||||||
|
_hover: {
|
||||||
|
borderColor: mode('gray.300', 'whiteAlpha.400')(props),
|
||||||
|
},
|
||||||
|
_invalid: {
|
||||||
|
borderColor: errorBorderColor,
|
||||||
|
boxShadow: `0 0 0 1px ${errorBorderColor}`,
|
||||||
|
},
|
||||||
|
_focusVisible: {
|
||||||
|
zIndex: 0,
|
||||||
|
borderColor: focusBorderColor,
|
||||||
|
boxShadow: `0 0 0 1px ${focusBorderColor}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectStyles = defineMultiStyleConfig({
|
||||||
|
variants: { outline },
|
||||||
|
});
|
15
src/theme/components/skeleton.ts
Normal file
15
src/theme/components/skeleton.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { cssVar, defineStyle, defineStyleConfig } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
const $startColor = cssVar('skeleton-start-color');
|
||||||
|
const $endColor = cssVar('skeleton-end-color');
|
||||||
|
|
||||||
|
export const skeletonStyles = defineStyleConfig({
|
||||||
|
baseStyle: defineStyle({
|
||||||
|
[$startColor.variable]: 'colors.navy.600',
|
||||||
|
[$endColor.variable]: 'colors.navy.800',
|
||||||
|
_light: {
|
||||||
|
[$startColor.variable]: 'colors.gray.200',
|
||||||
|
[$endColor.variable]: 'colors.gray.300',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
15
src/theme/components/slider.ts
Normal file
15
src/theme/components/slider.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { mode } from '@chakra-ui/theme-tools';
|
||||||
|
import { sliderAnatomy as parts } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
|
||||||
|
|
||||||
|
const { defineMultiStyleConfig, definePartsStyle } = createMultiStyleConfigHelpers(parts.keys);
|
||||||
|
|
||||||
|
export const sliderStyles = defineMultiStyleConfig({
|
||||||
|
variants: {
|
||||||
|
main: definePartsStyle((props) => ({
|
||||||
|
thumb: {
|
||||||
|
bg: mode('brand.500', 'brand.400')(props),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
43
src/theme/components/switch.ts
Normal file
43
src/theme/components/switch.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { switchAnatomy as parts } from '@chakra-ui/anatomy';
|
||||||
|
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
|
||||||
|
import { light, dark } from '../colors';
|
||||||
|
|
||||||
|
const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(parts.keys);
|
||||||
|
|
||||||
|
export const switchStyles = defineMultiStyleConfig({
|
||||||
|
baseStyle: definePartsStyle({
|
||||||
|
thumb: {
|
||||||
|
fontWeight: 400,
|
||||||
|
borderRadius: '50%',
|
||||||
|
w: '16px',
|
||||||
|
h: '16px',
|
||||||
|
_checked: { transform: 'translate(20px, 0px)' },
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
w: '40px',
|
||||||
|
h: '20px',
|
||||||
|
p: '2px',
|
||||||
|
ps: '2px',
|
||||||
|
_focus: {
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
_light: {
|
||||||
|
bg: 'gray.300',
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: 'navy.700',
|
||||||
|
},
|
||||||
|
_checked: {
|
||||||
|
_light: {
|
||||||
|
bg: light.brand,
|
||||||
|
},
|
||||||
|
_dark: {
|
||||||
|
bg: dark.brand,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue