initial commit

This commit is contained in:
Jonny_Bro (Nikita) 2024-07-15 13:18:31 +05:00
commit 316b63561a
No known key found for this signature in database
GPG key ID: 3F1ECC04147E9BD8
118 changed files with 11396 additions and 0 deletions

8
.env.example Normal file
View 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
View 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
View file

@ -0,0 +1,4 @@
/dist
/.next
/coverage
/node_modules

11
.prettierrc Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

BIN
document/home-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

46
document/localization.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

17
next.config.js Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

BIN
public/Banner1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

15
public/manifest.json Normal file
View 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
View file

@ -0,0 +1,3 @@
User-Agent: *
Disallow:
Sitemap: https://simmmple.com

141
src/api/bot.ts Normal file
View 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
View 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
View 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,
};
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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)} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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} />;
}

View 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>
);
};

View 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>
);
}

View 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>
)}
/>
);
}

View 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';

View 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>
);
};

View 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>
);
}

View 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;

View 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>
);
}

View 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';

View 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'>;
};

View 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>
);
}

View 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>
);
}

View 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']}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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" />
</>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
&nbsp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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,
},
};

View 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>
);
}

View 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),
};
};

View 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
View 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,
},
};

View 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;

View 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',
},
});

View 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',
},
});

View 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',
},
},
});

View 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',
},
},
});

View 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',
},
},
});

View 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',
},
});

View 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 });
},
};
}

View 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>;

View file

@ -0,0 +1,2 @@
export * from './custom-types';
export * from './types';

85
src/config/types/types.ts Normal file
View 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
View 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
View 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
View 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>
);
}

View 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`);
}

View 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);
}

View 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);
}

View 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
View 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: {},
};
};

View 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;

View 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;

View 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
View 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
View 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
View file

@ -0,0 +1 @@
export * from './pageStore';

32
src/stores/pageStore.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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)',
};

View 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,
},
}),
},
});

View 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',
},
},
});

View 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%)',
},
},
}),
},
});

View 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' },
},
}),
},
});

View 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,
},
},
}),
});

View 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,
});

View 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',
},
},
}),
});

View 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 },
});

View 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',
},
}),
});

View 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),
},
})),
},
});

View 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