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