style: code style changes

This commit is contained in:
Junseo Park 2021-02-28 11:58:03 +09:00
parent 9a36919657
commit 899f948fa6
72 changed files with 1914 additions and 1331 deletions

View File

@ -4,7 +4,7 @@ module.exports = {
node: true,
es6: true,
browser: true,
es2021: true
es2021: true,
},
ignorePatterns: ['node_modules/*', '.next/*', '.out/*', '!.prettierrc.js'],
extends: [
@ -12,20 +12,17 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended'
'plugin:jsx-a11y/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module'
sourceType: 'module',
},
plugins: [
'react',
'@typescript-eslint'
],
plugins: ['react', '@typescript-eslint'],
rules: {
'jsx-quotes': ['error', 'prefer-single'],
'react/no-unescaped-entities': 'off',
@ -36,17 +33,8 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn'],
indent: [
'error',
'tab'
],
quotes: [
'error',
'single'
],
semi: [
'error',
'never'
]
}
indent: ['error', 'tab'],
quotes: ['error', 'single'],
semi: ['error', 'never'],
},
}

View File

@ -1,32 +1,38 @@
---
name: 🐛 버그 제보
about: 버그를 제보해주세요!
title: "[버그] "
title: '[버그] '
labels: 'bug'
assignees: ''
---## 재현방법
---
## 재현방법
> 어떻게하면 발생시킬 수 있나요?
## 예상되는 정상적인 동작
> 정상이라면 어떻게 되야하나요?
## 발생한 문제
> 어떤 문제가 발생하나요?
## 클라이언트 버전
> 클라이언트 버전을 알려주세요!
<!--
클라이언트 버전을 가져오실 줄 모르신다면 아래 링크를 참고해주세요
https://github.com/koreanbots/docs/blob/master/version.md
-->
## 사양
> OS 정보와 브라우저의 버전을 알려주세요!
## 확인
- [ ] 중복되는 이슈는 없나요?
- [ ] 해당 버그를 다시 발생시킬 수 있나요?

View File

@ -12,4 +12,4 @@ jobs:
with:
reporter: github-pr-review
reviewdog_version: latest
eslint_flags: --ext js,jsx,ts,tsx .
eslint_flags: --ext js,jsx,ts,tsx .

View File

@ -7,57 +7,57 @@ jobs:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: install node v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: yarn install
run: yarn install
- name: run eslint
run: yarn lint
env:
CI: true
- uses: actions/checkout@v2
- name: install node v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: yarn install
run: yarn install
- name: run eslint
run: yarn lint
env:
CI: true
test:
name: Run Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: install node v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: yarn install
run: yarn install
- name: run jest
run: yarn test
- uses: actions/checkout@v2
- name: install node v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: yarn install
run: yarn install
- name: run jest
run: yarn test
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: install node v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: yarn install
run: yarn install
- name: Generate RSA Key Pair
run: |
ssh-keygen -b 2048 -t rsa -f key -q -P ""
ssh-keygen -b 2048 -e -m pem -f key -q -P "" > private.key
mv key public.pem
rm key.pub
- name: Setup environments
run: |
mv .env.demo.local .env.production.local
printf 'MARIADB_ROOT_PASSWORD=YOUSHALLNOTPASS\nCOMMIT_HASH=${{ github.sha }}' > .env
- name: Create needed files
run: echo '{"tester":"DEMO_KEY"}' > secret.json
- name: Build
run: yarn build
env:
CI: true
- uses: actions/checkout@v2
- name: install node v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: yarn install
run: yarn install
- name: Generate RSA Key Pair
run: |
ssh-keygen -b 2048 -t rsa -f key -q -P ""
ssh-keygen -b 2048 -e -m pem -f key -q -P "" > private.key
mv key public.pem
rm key.pub
- name: Setup environments
run: |
mv .env.demo.local .env.production.local
printf 'MARIADB_ROOT_PASSWORD=YOUSHALLNOTPASS\nCOMMIT_HASH=${{ github.sha }}' > .env
- name: Create needed files
run: echo '{"tester":"DEMO_KEY"}' > secret.json
- name: Build
run: yarn build
env:
CI: true
# docker:
# needs:
# - eslint
@ -75,7 +75,7 @@ jobs:
# run: |
# ssh-keygen -b 2048 -t rsa -f key -q -P ""
# ssh-keygen -b 2048 -e -m pem -f key -q -P "" > private.key
# mv key public.pem
# mv key public.pem
# rm key.pub
# - name: Setup environments
# run: |
@ -84,4 +84,4 @@ jobs:
# - name: Create needed files
# run: echo '{"tester":"DEMO_KEY"}' > secret.json
# - name: Docker Compose
# run: docker-compose up -d
# run: docker-compose up -d

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
.next/
node_modules/

12
.vscode/settings.json vendored
View File

@ -1,7 +1,7 @@
{
"editor.formatOnSave": false,
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
"editor.formatOnSave": false,
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

109
app.css
View File

@ -3,111 +3,114 @@
@tailwind utilities;
html {
scroll-behavior: smooth;
min-height: 100%;
overflow-x: hidden;
scroll-behavior: smooth;
min-height: 100%;
overflow-x: hidden;
}
body {
min-height: 100vh;
min-height: 100vh;
}
@font-face {
font-family: "Uni Sans Heavy CAPS";
src: url("/logofont.otf");
font-display: swap;
font-family: 'Uni Sans Heavy CAPS';
src: url('/logofont.otf');
font-display: swap;
}
.logofont {
font-family: "Uni Sans Heavy CAPS";
font-family: 'Uni Sans Heavy CAPS';
}
.animation-dropdown {
animation: dropdown 0.1s linear;
animation: dropdown 0.1s linear;
}
.iu-is-the-best {
min-height: 100vh;
min-height: 100vh;
}
.__control--is-focused {
border: none !important;
box-shadow: none !important;
border: none !important;
box-shadow: none !important;
}
i {
width: 20px
width: 20px;
}
html * ::-webkit-scrollbar {
-webkit-appearance: none;
width: 8px;
height: 8px;
-webkit-appearance: none;
width: 8px;
height: 8px;
}
html * ::-webkit-scrollbar-thumb {
cursor: pointer;
border-radius: 5px;
background: #ccc;
-webkit-transition: color .2s ease;
transition: color .2s ease;
cursor: pointer;
border-radius: 5px;
background: #ccc;
-webkit-transition: color 0.2s ease;
transition: color 0.2s ease;
}
html .dark * ::-webkit-scrollbar-thumb {
cursor: pointer;
border-radius: 5px;
background: #202225;
-webkit-transition: color .2s ease;
transition: color .2s ease;
cursor: pointer;
border-radius: 5px;
background: #202225;
-webkit-transition: color 0.2s ease;
transition: color 0.2s ease;
}
html * ::-webkit-scrollbar-track {
background: #f2f2f2;
border-radius: 0;
border: 4px solid transparent;
border-radius: 8px;
background: #f2f2f2;
border-radius: 0;
border: 4px solid transparent;
border-radius: 8px;
}
html .dark * ::-webkit-scrollbar-track {
background: #2e3338;
border-radius: 0;
background: #2e3338;
border-radius: 0;
}
.dark .__multi-value, .dark .__multi-value__label, .dark .__multi-value__remove {
background: #2e3338 !important;
.dark .__multi-value,
.dark .__multi-value__label,
.dark .__multi-value__remove {
background: #2e3338 !important;
}
.scroll-none {
-ms-overflow-style: none;
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-width: none;
}
.scroll-none ::-webkit-scrollbar {
display: none;
display: none;
}
button {
outline: none !important;
outline: none !important;
}
.emoji-selector-button {
width: 24px;
height: 24px;
display: inline-block;
background-image: url("https://unpkg.com/emoji-datasource-twitter@5.0.1/img/twitter/sheets-256/64.png");
background-size: 5700% 5700%;
background-position: 53.5714% 62.5%;
filter: grayscale(100%);
width: 24px;
height: 24px;
display: inline-block;
background-image: url('https://unpkg.com/emoji-datasource-twitter@5.0.1/img/twitter/sheets-256/64.png');
background-size: 5700% 5700%;
background-position: 53.5714% 62.5%;
filter: grayscale(100%);
}
.emoji-selector-button:hover {
filter: grayscale(0%);
transform: scale(1.1, 1.1);
opacity: 90%;
transition: ease-in 100ms;
cursor: pointer;
filter: grayscale(0%);
transform: scale(1.1, 1.1);
opacity: 90%;
transition: ease-in 100ms;
cursor: pointer;
}
.emoji-mart-category-list > *, .emoji-mart-emoji > span {
cursor: pointer;
}
.emoji-mart-category-list > *,
.emoji-mart-emoji > span {
cursor: pointer;
}

View File

@ -1,33 +1,45 @@
import Logger from '@utils/Logger'
import { useEffect } from 'react'
const Advertisement = ({ size='short' }:AdvertisementProps): JSX.Element => {
const Advertisement = ({ size = 'short' }: AdvertisementProps): JSX.Element => {
useEffect(() => {
if(process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === 'production') {
window.adsbygoogle = window.adsbygoogle || []
window.adsbygoogle.push({})
}
Logger.debug('Ads Pushed')
}, [])
return <div className={`z-0 mx-auto w-full text-center text-white ${process.env.NODE_ENV === 'production' ? '' : 'py-12 bg-gray-700'}`} style={size === 'short' ? { height: '90px' } : { height: '330px'}}>
{
process.env.NODE_ENV === 'production' ? <ins
className='adsbygoogle mb-5 w-full'
style={{ display: 'inline-block', height: '90px' }}
data-ad-client='ca-pub-4856582423981759'
data-ad-slot='3250141451'
data-adtest='on'
data-full-width-responsive='true'
></ins> : 'Advertisement'
}</div>
return (
<div
className={`z-0 mx-auto w-full text-center text-white ${
process.env.NODE_ENV === 'production' ? '' : 'py-12 bg-gray-700'
}`}
style={size === 'short' ? { height: '90px' } : { height: '330px' }}
>
{process.env.NODE_ENV === 'production' ? (
<ins
className='adsbygoogle mb-5 w-full'
style={{ display: 'inline-block', height: '90px' }}
data-ad-client='ca-pub-4856582423981759'
data-ad-slot='3250141451'
data-adtest='on'
data-full-width-responsive='true'
></ins>
) : (
'Advertisement'
)}
</div>
)
}
declare global {
interface Window { adsbygoogle: {
loaded?: boolean
push(obj: unknown): void
} }
interface Window {
adsbygoogle: {
loaded?: boolean
push(obj: unknown): void
}
}
}
interface AdvertisementProps {

View File

@ -1,19 +1,21 @@
import Link from 'next/link'
import DiscordAvatar from './DiscordAvatar'
const Application = ({ type, id, name }:ApplicationProps):JSX.Element => {
return <Link href={`/developers/applications/${type+'s'}/${id}`}>
<div className='relative py-4 px-2 bg-little-white dark:bg-discord-black text-center transform hover:-translate-y-1 transition duration-100 ease-in cursor-pointer rounded-lg'>
<DiscordAvatar userID={id} className='w-full rounded-xl px-2' />
<h2 className='text-xl font-medium pt-2 whitespace-nowrap truncate'>{name}</h2>
</div>
</Link>
const Application = ({ type, id, name }: ApplicationProps): JSX.Element => {
return (
<Link href={`/developers/applications/${type + 's'}/${id}`}>
<div className='relative px-2 py-4 text-center dark:bg-discord-black bg-little-white rounded-lg cursor-pointer transform hover:-translate-y-1 transition duration-100 ease-in'>
<DiscordAvatar userID={id} className='px-2 w-full rounded-xl' />
<h2 className='pt-2 whitespace-nowrap text-xl font-medium truncate'>{name}</h2>
</div>
</Link>
)
}
interface ApplicationProps {
type: 'bot'
id: string
name: string
type: 'bot'
id: string
name: string
}
export default Application
export default Application

View File

@ -8,19 +8,34 @@ import Divider from '@components/Divider'
import Tag from '@components/Tag'
import DiscordAvatar from '@components/DiscordAvatar'
const BotCard = ({ manage=false, bot }: BotProps): JSX.Element => {
const BotCard = ({ manage = false, bot }: BotProps): JSX.Element => {
return (
<div className='container mb-16 transform hover:-translate-y-1 transition duration-100 ease-in'>
<div className='relative'>
<div className='container mx-auto'>
<div className='h-full'>
<div className='relative mx-auto h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl' style={checkBotFlag(bot.flags, 'trusted') && bot.banner ? { background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${bot.banner}") center top / cover no-repeat`, color: 'white' } : {}}>
<div
className='relative mx-auto h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl'
style={
checkBotFlag(bot.flags, 'trusted') && bot.banner
? {
background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${bot.banner}") center top / cover no-repeat`,
color: 'white',
}
: {}
}
>
<Link href={makeBotURL(bot)}>
<a className='cursor-pointer'>
<div className='flex h-44'>
<div className='w-2/3'>
<div className='flex justify-start'>
<DiscordAvatar size={128} userID={bot.id} alt='Avatar' className='rounded-full absolute -left-2 -top-8 mx-auto w-32 h-32 bg-white'/>
<DiscordAvatar
size={128}
userID={bot.id}
alt='Avatar'
className='absolute -left-2 -top-8 mx-auto w-32 h-32 bg-white rounded-full'
/>
</div>
<div className='mt-28 px-4'>
@ -28,9 +43,7 @@ const BotCard = ({ manage=false, bot }: BotProps): JSX.Element => {
<i className={`fas fa-circle text-${Status[bot.status]?.color}`} />
{Status[bot.status]?.text}
</h2>
<h1 className='mb-3 text-left text-2xl font-bold truncate'>
{bot.name}
</h1>
<h1 className='mb-3 text-left text-2xl font-bold truncate'>{bot.name}</h1>
</div>
</div>
<div className='grid grid-cols-1 pr-5 py-5 w-1/3 h-0'>
@ -42,43 +55,53 @@ const BotCard = ({ manage=false, bot }: BotProps): JSX.Element => {
}
dark
/>
<Tag blurple text={bot.servers ? <>{formatNumber(bot.servers)} </> : 'N/A'} dark />
<Tag
blurple
text={bot.servers ? <>{formatNumber(bot.servers)} </> : 'N/A'}
dark
/>
</div>
</div>
<p className='px-4 text-left text-gray-400 text-sm font-medium mb-10 h-6'>{bot.intro}</p>
<div className='category px-2 flex flex-wrap'>
<p className='mb-10 px-4 h-6 text-left text-gray-400 text-sm font-medium'>
{bot.intro}
</p>
<div className='category flex flex-wrap px-2'>
{bot.category.slice(0, 3).map(el => (
<Tag key={el} text={el} href={`/categories/${el}`} dark/>
))} {
bot.category.length > 3 && <Tag text={`+${bot.category.length - 3}`} dark />
}
<Tag key={el} text={el} href={`/categories/${el}`} dark />
))}{' '}
{bot.category.length > 3 && <Tag text={`+${bot.category.length - 3}`} dark />}
</div>
</a>
</Link>
<Divider />
<div className='flex justify-evenly'>
<Link
href={makeBotURL(bot)}
>
<a className='rounded-bl-2xl py-3 w-full text-center text-koreanbots-blue hover:text-white text-sm font-bold hover:bg-koreanbots-blue hover:shadow-lg transition duration-100 ease-in'>
<Link href={makeBotURL(bot)}>
<a className='py-3 w-full text-center text-koreanbots-blue hover:text-white text-sm font-bold hover:bg-koreanbots-blue rounded-bl-2xl hover:shadow-lg transition duration-100 ease-in'>
</a>
</Link>
{
manage ? <Link href={`/manage/${bot.id}`}>
<a
className='rounded-br-2xl py-3 w-full text-center text-green-500 hover:text-white text-sm font-bold hover:bg-green-500 hover:shadow-lg transition duration-100 ease-in'
>
</a>
</Link> : <Link href={bot.url || `https://discordapp.com/oauth2/authorize?client_id=${bot.id}&scope=bot&permissions=0`}>
<a rel='noopener noreferrer' target='_blank'
className='rounded-br-2xl py-3 w-full text-center text-discord-blurple hover:text-white text-sm font-bold hover:bg-discord-blurple hover:shadow-lg transition duration-100 ease-in'
>
{manage ? (
<Link href={`/manage/${bot.id}`}>
<a className='py-3 w-full text-center text-green-500 hover:text-white text-sm font-bold hover:bg-green-500 rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'>
</a>
</Link>
}
) : (
<Link
href={
bot.url ||
`https://discordapp.com/oauth2/authorize?client_id=${bot.id}&scope=bot&permissions=0`
}
>
<a
rel='noopener noreferrer'
target='_blank'
className='py-3 w-full text-center text-discord-blurple hover:text-white text-sm font-bold hover:bg-discord-blurple rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'
>
</a>
</Link>
)}
</div>
</div>
</div>

View File

@ -1,24 +1,49 @@
import Link from 'next/link'
import { ReactNode } from 'react'
const Button = ({ type='button', className, children, href, onClick }: ButtonProps):JSX.Element => {
return href ? <Link href={href}>
<a className={`cursor-pointer rounded-md px-4 py-2 m-1 transition duration-300 ease select-none outline-none foucs:outline-none ${className ?? 'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}>
{ children }
</a>
</Link> : onClick ? <button type={type} onKeyDown={onClick} onClick={onClick} className={`cursor-pointer rounded-md px-4 py-2 m-0.5 transition duration-300 ease select-none outline-none foucs:outline-none ${className ?? 'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}>
{ children }
</button> : <button type={type} className={`cursor-pointer rounded-md px-4 py-2 m-0.5 transition duration-300 ease select-none outline-none foucs:outline-none ${className ?? 'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}>
{ children }
</button>
const Button = ({
type = 'button',
className,
children,
href,
onClick,
}: ButtonProps): JSX.Element => {
return href ? (
<Link href={href}>
<a
className={`cursor-pointer rounded-md px-4 py-2 m-1 transition duration-300 ease select-none outline-none foucs:outline-none ${className ??
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}
>
{children}
</a>
</Link>
) : onClick ? (
<button
type={type}
onKeyDown={onClick}
onClick={onClick}
className={`cursor-pointer rounded-md px-4 py-2 m-0.5 transition duration-300 ease select-none outline-none foucs:outline-none ${className ??
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}
>
{children}
</button>
) : (
<button
type={type}
className={`cursor-pointer rounded-md px-4 py-2 m-0.5 transition duration-300 ease select-none outline-none foucs:outline-none ${className ??
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}
>
{children}
</button>
)
}
interface ButtonProps {
type?: 'button' | 'submit' | 'reset'
className?: string
children: ReactNode
href?: string
onClick?: () => void
type?: 'button' | 'submit' | 'reset'
className?: string
children: ReactNode
href?: string
onClick?: () => void
}
export default Button
export default Button

View File

@ -1,18 +1,20 @@
const ColorCard = ({ header, first, second, className }:ColorCardProps):JSX.Element => {
return <div className={`rounded-lg p-10 ${className} shadow-lg`}>
<h2 className='text-2xl font-bold'>{header}</h2>
<p className='opacity-80'>
{first} <br/>
{second}
</p>
</div>
const ColorCard = ({ header, first, second, className }: ColorCardProps): JSX.Element => {
return (
<div className={`rounded-lg p-10 ${className} shadow-lg`}>
<h2 className='text-2xl font-bold'>{header}</h2>
<p className='opacity-80'>
{first} <br />
{second}
</p>
</div>
)
}
interface ColorCardProps {
header: string
first: string
second: string
className: string
header: string
first: string
second: string
className: string
}
export default ColorCard
export default ColorCard

View File

@ -8,9 +8,9 @@ const Container = ({
}: ContainerProps): JSX.Element => {
return (
<div
className={`${
ignoreColor ? '' : 'text-black dark:text-gray-100'
} ${paddingTop ? 'pt-20' : ''}`}
className={`${ignoreColor ? '' : 'text-black dark:text-gray-100'} ${
paddingTop ? 'pt-20' : ''
}`}
>
<div className={`container mx-auto px-4 ${className}`}>{children}</div>
</div>

View File

@ -5,7 +5,10 @@ import SEO from './SEO'
const Docs = ({ title, header, description, subheader, children }: DocsProps): JSX.Element => {
return (
<>
<SEO title={typeof header === 'string' ? header : title} description={description || subheader} />
<SEO
title={typeof header === 'string' ? header : title}
description={description || subheader}
/>
<div className='dark:bg-discord-black bg-discord-blurple'>
<Container className='pb-10 pt-20' ignoreColor>
<h1 className='mt-10 text-center text-gray-100 text-4xl font-bold sm:text-left'>
@ -21,7 +24,7 @@ const Docs = ({ title, header, description, subheader, children }: DocsProps): J
</div>
<Wave
color='currentColor'
className='dark:text-discord-black text-discord-blurple dark:bg-discord-dark bg-white hidden md:block'
className='hidden dark:text-discord-black text-discord-blurple dark:bg-discord-dark bg-white md:block'
/>
<Container>
<div>{children}</div>

View File

@ -5,10 +5,14 @@ import Wave from '@components/Wave'
import Toggle from './Toggle'
import { Theme } from '@types'
const Footer = ({ color, theme, setTheme }:FooterProps): JSX.Element => {
const Footer = ({ color, theme, setTheme }: FooterProps): JSX.Element => {
return (
<div className='releative z-30'>
<Wave color='currentColor' className={`${color ?? 'dark:text-discord-dark text-white bg-discord-black'} hidden md:block`} />
<Wave
color='currentColor'
className={`${color ??
'dark:text-discord-dark text-white bg-discord-black'} hidden md:block`}
/>
<div className='bottom-0 text-white bg-discord-black'>
<Container className='pb-20 pt-10 w-11/12 lg:flex lg:pt-0 lg:w-4/5' ignoreColor>
<div className='w-full md:w-2/5'>
@ -28,8 +32,8 @@ const Footer = ({ color, theme, setTheme }:FooterProps): JSX.Element => {
</a>
</div>
</div>
<div className='flex-grow grid grid-cols-2 md:grid-cols-7 gap-2'>
<div className='mb-2 col-span-2'>
<div className='grid flex-grow gap-2 grid-cols-2 md:grid-cols-7'>
<div className='col-span-2 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'> </h2>
<ul className='text-sm'>
<li>
@ -44,7 +48,7 @@ const Footer = ({ color, theme, setTheme }:FooterProps): JSX.Element => {
</li>
</ul>
</div>
<div className='mb-2 col-span-1'>
<div className='col-span-1 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'></h2>
<ul className='text-sm'>
<li>
@ -59,7 +63,7 @@ const Footer = ({ color, theme, setTheme }:FooterProps): JSX.Element => {
</li>
</ul>
</div>
<div className='mb-2 col-span-1'>
<div className='col-span-1 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'></h2>
<ul className='text-sm'>
<li>
@ -79,19 +83,21 @@ const Footer = ({ color, theme, setTheme }:FooterProps): JSX.Element => {
</li>
</ul>
</div>
<div className='mb-2 col-span-2'>
<div className='col-span-2 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'></h2>
<div className='flex'>
<a className='hover:text-gray-300 mr-2'></a>
<Toggle checked={theme === 'dark'} onChange={() => {
const t = theme === 'dark' ? 'light' : 'dark'
setTheme(t)
localStorage.setItem('theme', t)
}} />
<a className='mr-2 hover:text-gray-300'></a>
<Toggle
checked={theme === 'dark'}
onChange={() => {
const t = theme === 'dark' ? 'light' : 'dark'
setTheme(t)
localStorage.setItem('theme', t)
}}
/>
</div>
</div>
</div>
</Container>
</div>
</div>

View File

@ -1,12 +1,12 @@
import { Field } from 'formik'
const CheckBox = ({ name, ...props }:CheckBoxProps):JSX.Element => {
return <Field type='checkbox' name={name} className='mr-1 h-4 w-4 rounded' {...props}/>
const CheckBox = ({ name, ...props }: CheckBoxProps): JSX.Element => {
return <Field type='checkbox' name={name} className='mr-1 w-4 h-4 rounded' {...props} />
}
interface CheckBoxProps {
name: string
[key: string]: unknown
name: string
[key: string]: unknown
}
export default CheckBox
export default CheckBox

View File

@ -1,11 +1,11 @@
import { Field } from 'formik'
const CsrfToken = ({ token }:CsrfTokenProps):JSX.Element => {
const CsrfToken = ({ token }: CsrfTokenProps): JSX.Element => {
return <Field name='_csrf' hidden value={token} readOnly />
}
interface CsrfTokenProps {
token: string
token: string
}
export default CsrfToken
export default CsrfToken

View File

@ -1,12 +1,18 @@
import { Field } from 'formik'
const Input = ({ name, placeholder }:InputProps):JSX.Element => {
return <Field name={name} className='border border-grey-light dark:border-transparent text-black dark:bg-very-black dark:text-white w-full h-10 rounded px-3 relative outline-none' placeholder={placeholder}/>
const Input = ({ name, placeholder }: InputProps): JSX.Element => {
return (
<Field
name={name}
className='border-grey-light relative px-3 w-full h-10 text-black dark:text-white dark:bg-very-black border dark:border-transparent rounded outline-none'
placeholder={placeholder}
/>
)
}
interface InputProps {
name: string
placeholder?: string
name: string
placeholder?: string
}
export default Input
export default Input

View File

@ -1,33 +1,48 @@
const Label = ({ For, children, label, labelDesc, error=null, grid=true, short=false, required=false }:LabelProps):JSX.Element => {
return <>
<label className={grid ? 'grid grid-cols-1 xl:grid-cols-4 gap-2 my-4' : 'inline-flex items-center'} htmlFor={For}>
{
label && <div className='col-span-1 text-sm'>
<h3 className='text-lg font-bold text-discord-blurple'>{label}
{
required && <span className='text-base font-semibold align-text-top text-red-500'> *</span>
}
</h3>
{labelDesc}
const Label = ({
For,
children,
label,
labelDesc,
error = null,
grid = true,
short = false,
required = false,
}: LabelProps): JSX.Element => {
return (
<>
<label
className={grid ? 'grid grid-cols-1 xl:grid-cols-4 gap-2 my-4' : 'inline-flex items-center'}
htmlFor={For}
>
{label && (
<div className='col-span-1 text-sm'>
<h3 className='text-discord-blurple text-lg font-bold'>
{label}
{required && (
<span className='align-text-top text-red-500 text-base font-semibold'> *</span>
)}
</h3>
{labelDesc}
</div>
)}
<div className={short ? 'col-span-1' : 'col-span-3'}>
{children}
<div className='mt-1 text-red-500 text-xs font-light'>{error}</div>
</div>
}
<div className={short ? 'col-span-1' : 'col-span-3'}>
{children}
<div className='text-red-500 text-xs font-light mt-1'>{error}</div>
</div>
</label>
</>
</label>
</>
)
}
interface LabelProps {
For: string
children: JSX.Element | JSX.Element[]
label?: string
labelDesc?: string | JSX.Element
error?: string | null
grid?: boolean
short?: boolean
required?: boolean
interface LabelProps {
For: string
children: JSX.Element | JSX.Element[]
label?: string
labelDesc?: string | JSX.Element
error?: string | null
grid?: boolean
short?: boolean
required?: boolean
}
export default Label
export default Label

View File

@ -1,28 +1,43 @@
import ReactSelect from 'react-select'
const Select = ({ placeholder, options, handleChange, handleTouch }:SelectProps):JSX.Element => {
return <ReactSelect styles={{
control: (provided) => {
return { ...provided, border: 'none' }
},
option: (provided) => {
return { ...provided, cursor: 'pointer', ':hover': {
opacity: '0.7'
} }
}
}} className='border border-grey-light dark:border-transparent rounded' classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white ' placeholder={placeholder || '선택해주세요.'} options={options} onChange={handleChange} onBlur={handleTouch} noOptionsMessage={() => '검색 결과가 없습니다.'}/>
const Select = ({ placeholder, options, handleChange, handleTouch }: SelectProps): JSX.Element => {
return (
<ReactSelect
styles={{
control: provided => {
return { ...provided, border: 'none' }
},
option: provided => {
return {
...provided,
cursor: 'pointer',
':hover': {
opacity: '0.7',
},
}
},
}}
className='border-grey-light border dark:border-transparent rounded'
classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white '
placeholder={placeholder || '선택해주세요.'}
options={options}
onChange={handleChange}
onBlur={handleTouch}
noOptionsMessage={() => '검색 결과가 없습니다.'}
/>
)
}
interface SelectProps {
placeholder?: string
handleChange: (value: Option) => void
handleTouch: () => void
options: Option[]
placeholder?: string
handleChange: (value: Option) => void
handleTouch: () => void
options: Option[]
}
interface Option {
value: string
label: string
value: string
label: string
}
export default Select
export default Select

View File

@ -1,14 +1,20 @@
const Loader = ({ text, visible=true }:LoaderProps):JSX.Element => {
return <div className={`${visible ? '' : 'hidden '}w-full h-full fixed block top-0 left-0 bg-gray-500 bg-opacity-75 z-50 dark:text-black`}>
<h1 className='text-2xl font-semibold opacity-100 top-1/2 my-0 mx-auto block relative text-center'>
{ text }
</h1>
</div>
const Loader = ({ text, visible = true }: LoaderProps): JSX.Element => {
return (
<div
className={`${
visible ? '' : 'hidden '
}w-full h-full fixed block top-0 left-0 bg-gray-500 bg-opacity-75 z-50 dark:text-black`}
>
<h1 className='relative top-1/2 block mx-auto my-0 text-center text-2xl font-semibold opacity-100'>
{text}
</h1>
</div>
)
}
interface LoaderProps {
text: string
visible?: boolean
text: string
visible?: boolean
}
export default Loader
export default Loader

View File

@ -3,26 +3,107 @@ import MarkdownView from 'react-showdown'
import sanitizeHtml from 'sanitize-html'
import Emoji from 'node-emoji'
const Markdown = ({ text }:MarkdownProps):JSX.Element => {
return <div className='w-full markdown-body'>
<MarkdownView markdown={Emoji.emojify(text)} extensions={[ twemoji, customEmoji, anchorHeader ]} options={{ openLinksInNewWindow: true, underline: true, omitExtraWLInCodeBlocks: true, literalMidWordUnderscores: true, simplifiedAutoLink: true, tables: true, strikethrough: true, smoothLivePreview: true, tasklists: true, ghCompatibleHeaderId: true, encodeEmails: true }} sanitizeHtml={(html)=> sanitizeHtml(html, {
allowedTags: [
'addr', 'address', 'article', 'aside', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'section', 'blockquote', 'dd', 'div',
'dl', 'dt', 'hr', 'li', 'ol', 'p', 'pre',
'ul', 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn',
'em', 'i', 'kbd', 'mark', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp',
'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr', 'caption',
'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'del',
'img', 'svg', 'path', 'input'
],
allowedAttributes: false
})} />
</div>
const Markdown = ({ text }: MarkdownProps): JSX.Element => {
return (
<div className='markdown-body w-full'>
<MarkdownView
markdown={Emoji.emojify(text)}
extensions={[twemoji, customEmoji, anchorHeader]}
options={{
openLinksInNewWindow: true,
underline: true,
omitExtraWLInCodeBlocks: true,
literalMidWordUnderscores: true,
simplifiedAutoLink: true,
tables: true,
strikethrough: true,
smoothLivePreview: true,
tasklists: true,
ghCompatibleHeaderId: true,
encodeEmails: true,
}}
sanitizeHtml={html =>
sanitizeHtml(html, {
allowedTags: [
'addr',
'address',
'article',
'aside',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'section',
'blockquote',
'dd',
'div',
'dl',
'dt',
'hr',
'li',
'ol',
'p',
'pre',
'ul',
'a',
'abbr',
'b',
'bdi',
'bdo',
'br',
'cite',
'code',
'data',
'dfn',
'em',
'i',
'kbd',
'mark',
'q',
'rb',
'rp',
'rt',
'rtc',
'ruby',
's',
'samp',
'small',
'span',
'strong',
'sub',
'sup',
'time',
'u',
'var',
'wbr',
'caption',
'col',
'colgroup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'del',
'img',
'svg',
'path',
'input',
],
allowedAttributes: false,
})
}
/>
</div>
)
}
interface MarkdownProps {
text: string
text: string
}
export default Markdown
export default Markdown

View File

@ -1,14 +1,18 @@
import { MessageColor } from '@utils/Constants'
const Message = ({ type, children }:MessageProps):JSX.Element => {
return <div className={`${MessageColor[type]} px-6 py-4 rounded-md text-base mx-auto w-full text-left`}>
{children}
</div>
const Message = ({ type, children }: MessageProps): JSX.Element => {
return (
<div
className={`${MessageColor[type]} px-6 py-4 rounded-md text-base mx-auto w-full text-left`}
>
{children}
</div>
)
}
interface MessageProps{
type?: 'success' | 'error' | 'warning' | 'info'
children: JSX.Element | JSX.Element[] | string
interface MessageProps {
type?: 'success' | 'error' | 'warning' | 'info'
children: JSX.Element | JSX.Element[] | string
}
export default Message
export default Message

View File

@ -1,35 +1,40 @@
import { ReactNode } from 'react'
import { Modal as ReactModal} from 'react-responsive-modal'
import { Modal as ReactModal } from 'react-responsive-modal'
import 'react-responsive-modal/styles.css'
const Modal = ({ children, isOpen, onClose, dark, header }:ModalProps):JSX.Element => {
return <ReactModal open={isOpen} onClose={onClose} center animationDuration={100} classNames={{
modal: 'bg-discord-dark'
}}
showCloseIcon={false}
styles={{
modal: {
borderRadius: '10px',
background: dark ? '#2C2F33' : '#fbfbfb',
color: dark ? 'white' : 'black'
}
}}
>
<h2 className='text-lg font-black uppercase'>{header}</h2>
<div className='pt-4 relative'>
<div>
{children}
const Modal = ({ children, isOpen, onClose, dark, header }: ModalProps): JSX.Element => {
return (
<ReactModal
open={isOpen}
onClose={onClose}
center
animationDuration={100}
classNames={{
modal: 'bg-discord-dark',
}}
showCloseIcon={false}
styles={{
modal: {
borderRadius: '10px',
background: dark ? '#2C2F33' : '#fbfbfb',
color: dark ? 'white' : 'black',
},
}}
>
<h2 className='text-lg font-black uppercase'>{header}</h2>
<div className='relative pt-4'>
<div>{children}</div>
</div>
</div>
</ReactModal>
</ReactModal>
)
}
interface ModalProps {
dark: boolean
isOpen: boolean
header?: string
children: ReactNode
onClose(): void
dark: boolean
isOpen: boolean
header?: string
children: ReactNode
onClose(): void
}
export default Modal
export default Modal

View File

@ -1,11 +1,11 @@
const Notice = ({ header, desc }:NoticeProps) => {
const Notice = ({ header, desc }: NoticeProps) => {
return (
<div className='py-48 px-10 mx-auto my-auto h-screen text-center'>
<div className='mx-auto my-auto px-10 py-48 h-screen text-center'>
<h1 className='text-4xl font-bold'>KOREANBOTS</h1>
<br />
<div>
<h1 className='text-3xl font-bold mb-10'>{header}</h1>
<h1 className='mb-10 text-3xl font-bold'>{header}</h1>
<h2 className='text-lg font-semibold'>{desc}</h2>
<br />
</div>
@ -18,4 +18,4 @@ export default Notice
interface NoticeProps {
header: string
desc: string
}
}

View File

@ -1,24 +1,26 @@
import Link from 'next/link'
import DiscordAvatar from '@components/DiscordAvatar'
const Owner = ({ id, username, tag }:OwnerProps):JSX.Element => {
return <Link href={`/users/${id}`}>
<a className='text-base bg-little-white dark:bg-discord-black text-black dark:text-gray-400 rounded flex hover:bg-little-white-hover dark:hover:bg-discord-dark-hover cursor-pointer px-4 py-4 mb-1'>
<div className='rounded-full h-8 w-8 flex-shrink-0 mr-3 mt-1 overflow-hidden shadow-inner relative'>
<DiscordAvatar userID={id} className='absolute inset-0 z-negative w-full h-full'/>
</div>
<div className='flex-1 leading-snug w-0'>
<h4 className='whitespace-nowrap'>{username}
</h4><span className='text-sm text-gray-600'>#{tag}</span>
</div>
</a>
</Link>
const Owner = ({ id, username, tag }: OwnerProps): JSX.Element => {
return (
<Link href={`/users/${id}`}>
<a className='dark:hover:bg-discord-dark-hover flex mb-1 px-4 py-4 text-black dark:text-gray-400 text-base dark:bg-discord-black bg-little-white hover:bg-little-white-hover rounded cursor-pointer'>
<div className='relative flex-shrink-0 mr-3 mt-1 w-8 h-8 rounded-full shadow-inner overflow-hidden'>
<DiscordAvatar userID={id} className='z-negative absolute inset-0 w-full h-full' />
</div>
<div className='flex-1 w-0 leading-snug'>
<h4 className='whitespace-nowrap'>{username}</h4>
<span className='text-gray-600 text-sm'>#{tag}</span>
</div>
</a>
</Link>
)
}
export default Owner
interface OwnerProps {
id: string
tag: string
username: string
}
id: string
tag: string
username: string
}

View File

@ -1,36 +1,82 @@
import Link from 'next/link'
const Paginator = ({ currentPage, totalPage, pathname }:PaginatorProps):JSX.Element => {
const Paginator = ({ currentPage, totalPage, pathname }: PaginatorProps): JSX.Element => {
let pages = []
if(currentPage < 4) pages = [ 1, totalPage < 2 ? null : 2, totalPage < 3 ? null : 3, totalPage < 4 ? null : 4, totalPage < 5 ? null : 5 ]
else if(currentPage > totalPage - 3) pages = [ totalPage - 4 < 1 ? null : totalPage - 4, totalPage - 3 < 1 ? null : totalPage - 3, totalPage - 2 < 1 ? null : totalPage - 2, totalPage - 1 < 1 ? null : totalPage - 1, totalPage ]
else pages = [ currentPage - 2 < 1 ? null : currentPage - 2, currentPage - 1 < 1 ? null : currentPage - 1, currentPage, currentPage + 1 > totalPage ? null : currentPage + 1, currentPage + 2 > totalPage ? null : currentPage + 2 ]
if (currentPage < 4)
pages = [
1,
totalPage < 2 ? null : 2,
totalPage < 3 ? null : 3,
totalPage < 4 ? null : 4,
totalPage < 5 ? null : 5,
]
else if (currentPage > totalPage - 3)
pages = [
totalPage - 4 < 1 ? null : totalPage - 4,
totalPage - 3 < 1 ? null : totalPage - 3,
totalPage - 2 < 1 ? null : totalPage - 2,
totalPage - 1 < 1 ? null : totalPage - 1,
totalPage,
]
else
pages = [
currentPage - 2 < 1 ? null : currentPage - 2,
currentPage - 1 < 1 ? null : currentPage - 1,
currentPage,
currentPage + 1 > totalPage ? null : currentPage + 1,
currentPage + 2 > totalPage ? null : currentPage + 2,
]
pages = pages.filter(el => el)
return <div className='flex flex-col items-center py-4 text-center justify-center'>
<div className='flex'>
<Link href={{ pathname, query: { page: currentPage - 1} }}>
<a className={`${currentPage === 1 ? 'invisible' : ''} h-12 w-12 mr-1 flex justify-center items-center rounded-full transition duration-150 ease-in bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover cursor-pointer text-center`}>
<i className='fas fa-chevron-left'></i>
</a>
</Link>
{
pages.map((el, i) => <Link key={i} href={{ pathname, query: { page: el } }}>
<a className={`w-12 flex justify-center items-center cursor-pointer leading-5 transition duration-150 ease-in ${i===0 && i===pages.length-1 ? 'rounded-full' : i===0 ? 'rounded-l-full' : i===pages.length-1 ? 'rounded-r-full' : ''} ${currentPage === el ? 'bg-gray-300 dark:bg-discord-dark-hover' : 'bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover'}`}>{el}</a>
</Link>)
}
<Link href={{ pathname, query: { page: currentPage + 1} }}>
<a className={`${currentPage === totalPage ? 'invisible' : '' } h-12 w-12 ml-1 flex justify-center items-center rounded-full transition duration-150 ease-in bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover cursor-pointer text-center`}>
<i className='fas fa-chevron-right'></i>
</a>
</Link>
return (
<div className='flex flex-col items-center justify-center py-4 text-center'>
<div className='flex'>
<Link href={{ pathname, query: { page: currentPage - 1 } }}>
<a
className={`${
currentPage === 1 ? 'invisible' : ''
} h-12 w-12 mr-1 flex justify-center items-center rounded-full transition duration-150 ease-in bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover cursor-pointer text-center`}
>
<i className='fas fa-chevron-left'></i>
</a>
</Link>
{pages.map((el, i) => (
<Link key={i} href={{ pathname, query: { page: el } }}>
<a
className={`w-12 flex justify-center items-center cursor-pointer leading-5 transition duration-150 ease-in ${
i === 0 && i === pages.length - 1
? 'rounded-full'
: i === 0
? 'rounded-l-full'
: i === pages.length - 1
? 'rounded-r-full'
: ''
} ${
currentPage === el
? 'bg-gray-300 dark:bg-discord-dark-hover'
: 'bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover'
}`}
>
{el}
</a>
</Link>
))}
<Link href={{ pathname, query: { page: currentPage + 1 } }}>
<a
className={`${
currentPage === totalPage ? 'invisible' : ''
} h-12 w-12 ml-1 flex justify-center items-center rounded-full transition duration-150 ease-in bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover cursor-pointer text-center`}
>
<i className='fas fa-chevron-right'></i>
</a>
</Link>
</div>
</div>
</div>
)
}
interface PaginatorProps {
pathname: string
currentPage: number
totalPage: number
currentPage: number
totalPage: number
}
export default Paginator
export default Paginator

View File

@ -10,21 +10,26 @@ import DiscordAvatar from '@components/DiscordAvatar'
const Search = (): JSX.Element => {
const router = useRouter()
const [ query, setQuery ] = useState('')
const [ data, setData ] = useState<ResponseProps<BotList>>(null)
const [ loading, setLoading ] = useState(false)
const [ abortControl, setAbortControl ] = useState(new AbortController())
const [ hidden, setHidden ] = useState(true)
const [query, setQuery] = useState('')
const [data, setData] = useState<ResponseProps<BotList>>(null)
const [loading, setLoading] = useState(false)
const [abortControl, setAbortControl] = useState(new AbortController())
const [hidden, setHidden] = useState(true)
const SearchResults = async (value: string) => {
setQuery(value)
try { abortControl.abort() } catch { return null }
try {
abortControl.abort()
} catch {
return null
}
const controller = new AbortController()
setAbortControl(controller)
if(value.length > 2) setLoading(true)
const res = await Fetch<BotList>(`/search/bots?q=${encodeURIComponent(value)}`, { signal: controller.signal })
if (value.length > 2) setLoading(true)
const res = await Fetch<BotList>(`/search/bots?q=${encodeURIComponent(value)}`, {
signal: controller.signal,
})
setData(res)
setLoading(false)
}
const onSubmit = async () => {
@ -32,39 +37,84 @@ const Search = (): JSX.Element => {
redirectTo(router, `/search/?q=${encodeURIComponent(query)}`)
}
return <div>
<div onFocus={() => setHidden(false)} onBlur={() => setTimeout(() => setHidden(true), 80)} className='relative w-full mt-5 text-black bg-white dark:text-gray-100 dark:bg-very-black flex rounded-lg z-10'>
<input maxLength={50} className='bg-transparent flex-grow outline-none border-none shadow border-0 py-3 px-7 pr-20 h-16 text-xl' placeholder='검색...' value={query} onChange={(e)=> {
SearchResults(e.target.value)
}} onKeyDown={(e) => {
if(e.key === 'Enter') return onSubmit()
}} />
<button className='outline-none cusor-pointer absolute right-0 top-0 mt-5 mr-5' onClick={onSubmit}>
<i className='text-gray-600 hover:text-gray-700 text-2xl fas fa-search' />
</button>
</div>
<div className={`relative ${hidden ? 'hidden' : 'block'}`}>
<div className='absolute rounded shadow-md my-2 pin-t pin-l text-black bg-white dark:text-gray-100 dark:bg-very-black h-60 md:h-80 overflow-y-scroll w-full'>
<ul>
{
data && data.code === 200 && data.data ? data.data.data.length === 0 ? <li className='px-3 py-3.5'> .</li> :
data.data.data.map(el => <Link key={el.id} href={makeBotURL(el)}>
<li className='px-3 py-2 flex h-15 cursor-pointer'>
<DiscordAvatar className='w-12 h-12 mt-1' size={128} userID={el.id} />
<div className='ml-2'>
<h1 className='text-lg text-black dark:text-gray-100'>{el.name}</h1>
<p className='text-sm text-gray-400'>
{el.intro}
</p>
</div>
</li>
</Link>) : loading ? <li className='px-3 py-3.5'>...</li> : <li className='px-3 py-3.5'>{query && data ? data.message?.includes('문법') ? <> .<br/><a className='text-blue-500 hover:text-blue-400' href='https://docs.koreanbots.dev/bots/usage/search' target='_blank' rel='noreferrer' > </a></> : data.errors && data.errors[0] || data.message : query.length < 3 ? '최소 2글자 이상 입력해주세요.' : '검색어를 입력해주세요.'}</li>
}
</ul>
return (
<div>
<div
onFocus={() => setHidden(false)}
onBlur={() => setTimeout(() => setHidden(true), 80)}
className='relative z-10 flex mt-5 w-full text-black dark:text-gray-100 dark:bg-very-black bg-white rounded-lg'
>
<input
maxLength={50}
className='flex-grow pr-20 px-7 py-3 h-16 text-xl bg-transparent border-0 border-none outline-none shadow'
placeholder='검색...'
value={query}
onChange={e => {
SearchResults(e.target.value)
}}
onKeyDown={e => {
if (e.key === 'Enter') return onSubmit()
}}
/>
<button
className='cusor-pointer absolute right-0 top-0 mr-5 mt-5 outline-none'
onClick={onSubmit}
>
<i className='fas fa-search text-gray-600 hover:text-gray-700 text-2xl' />
</button>
</div>
<div className={`relative ${hidden ? 'hidden' : 'block'}`}>
<div className='pin-t pin-l absolute my-2 w-full h-60 text-black dark:text-gray-100 dark:bg-very-black bg-white rounded shadow-md overflow-y-scroll md:h-80'>
<ul>
{data && data.code === 200 && data.data ? (
data.data.data.length === 0 ? (
<li className='px-3 py-3.5'> .</li>
) : (
data.data.data.map(el => (
<Link key={el.id} href={makeBotURL(el)}>
<li className='h-15 flex px-3 py-2 cursor-pointer'>
<DiscordAvatar className='mt-1 w-12 h-12' size={128} userID={el.id} />
<div className='ml-2'>
<h1 className='text-black dark:text-gray-100 text-lg'>{el.name}</h1>
<p className='text-gray-400 text-sm'>{el.intro}</p>
</div>
</li>
</Link>
))
)
) : loading ? (
<li className='px-3 py-3.5'>...</li>
) : (
<li className='px-3 py-3.5'>
{query && data ? (
data.message?.includes('문법') ? (
<>
.
<br />
<a
className='hover:text-blue-400 text-blue-500'
href='https://docs.koreanbots.dev/bots/usage/search'
target='_blank'
rel='noreferrer'
>
</a>
</>
) : (
(data.errors && data.errors[0]) || data.message
)
) : query.length < 3 ? (
'최소 2글자 이상 입력해주세요.'
) : (
'검색어를 입력해주세요.'
)}
</li>
)}
</ul>
</div>
</div>
</div>
</div>
)
}
export default Search

View File

@ -1,6 +1,8 @@
const Segment = ({ children, className='' }:SegmentProps): JSX.Element => {
const Segment = ({ children, className = '' }: SegmentProps): JSX.Element => {
return (
<div className={`py-3 px-7 text-black dark:text-white dark:bg-discord-black bg-little-white rounded-sm ${className}`}>
<div
className={`py-3 px-7 text-black dark:text-white dark:bg-discord-black bg-little-white rounded-sm ${className}`}
>
{children}
</div>
)

View File

@ -5,34 +5,43 @@ import Tag from '@components/Tag'
import { SubmittedBot } from '@types'
import Link from 'next/link'
const SubmittedBotCard = ({ href, submit }:SubmittedBotProps):JSX.Element => {
return <Link href={href}>
<a className='relative mx-auto w-full h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl px-4 py-5 transform hover:-translate-y-1 transition duration-100 ease-in'>
<div className='h-18'>
<div className='flex'>
<div className='flex-grow w-full'>
<h2 className='text-lg'>{submit.id}</h2>
</div>
<div className='grid grid-cols-1 px-4 w-2/5 h-0 absolute right-0'>
<Tag
text={
<>
<i className={`fas fa-circle text-${[Status.offline, Status.online, Status.dnd][submit.state]?.color}`} />
{' '}{['대기중', '승인됨', '거부됨'][submit.state]}
</>
}
dark
/>
const SubmittedBotCard = ({ href, submit }: SubmittedBotProps): JSX.Element => {
return (
<Link href={href}>
<a className='relative mx-auto px-4 py-5 w-full h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl transform hover:-translate-y-1 transition duration-100 ease-in'>
<div className='h-18'>
<div className='flex'>
<div className='flex-grow w-full'>
<h2 className='text-lg'>{submit.id}</h2>
</div>
<div className='absolute right-0 grid grid-cols-1 px-4 w-2/5 h-0'>
<Tag
text={
<>
<i
className={`fas fa-circle text-${
[Status.offline, Status.online, Status.dnd][submit.state]?.color
}`}
/>{' '}
{['대기중', '승인됨', '거부됨'][submit.state]}
</>
}
dark
/>
</div>
</div>
<p className='mt-1.5 w-full h-6 text-left text-gray-400 text-sm font-medium truncate'>
{submit.intro.slice(0, 25)}
{submit.intro.length > 25 && '...'}
</p>
</div>
<p className='text-left truncate text-gray-400 text-sm font-medium mt-1.5 h-6 w-full'>{submit.intro.slice(0, 25)}{submit.intro.length > 25 && '...'}</p>
</div>
</a>
</Link>
</a>
</Link>
)
}
interface SubmittedBotProps {
href: string
submit: SubmittedBot
submit: SubmittedBot
}
export default SubmittedBotCard
export default SubmittedBotCard

View File

@ -13,30 +13,13 @@ const Tag = ({
bigger = false,
...props
}: LabelProps): JSX.Element => {
return href ? newTab ? (
<a
href={href}
rel='noopener noreferrer'
target='_blank'
className={`${className ?? ''} text-center text-base ${
dark
? blurple
? 'bg-discord-blurple text-white'
: 'bg-little-white-hover hover:bg-little-white dark:bg-very-black'
: github
? 'bg-gray-900 text-white hover:bg-gray-700'
: 'bg-little-white dark:bg-discord-black hover:bg-little-white-hover'
} ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${
circular ? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}` : `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
} mr-1 mb-${marginBottom} dark:hover:bg-discord-dark-hover transition duration-100 ease-in`}
>
{text}
</a>
) : (
<Link href={href}>
return href ? (
newTab ? (
<a
className={`${className ?? ''} text-center text-base ${
href={href}
rel='noopener noreferrer'
target='_blank'
className={`${className ?? ''} text-center text-base ${
dark
? blurple
? 'bg-discord-blurple text-white'
@ -44,13 +27,37 @@ const Tag = ({
: github
? 'bg-gray-900 text-white hover:bg-gray-700'
: 'bg-little-white dark:bg-discord-black hover:bg-little-white-hover'
} ${!blurple && !github ? 'text-black dark:text-gray-400' : 'hover:bg-little-white-hover'} ${
circular ? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}` : `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
} ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${
circular
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
} mr-1 mb-${marginBottom} dark:hover:bg-discord-dark-hover transition duration-100 ease-in`}
>
{text}
</a>
</Link>
) : (
<Link href={href}>
<a
className={`${className ?? ''} text-center text-base ${
dark
? blurple
? 'bg-discord-blurple text-white'
: 'bg-little-white-hover hover:bg-little-white dark:bg-very-black'
: github
? 'bg-gray-900 text-white hover:bg-gray-700'
: 'bg-little-white dark:bg-discord-black hover:bg-little-white-hover'
} ${
!blurple && !github ? 'text-black dark:text-gray-400' : 'hover:bg-little-white-hover'
} ${
circular
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
} mr-1 mb-${marginBottom} dark:hover:bg-discord-dark-hover transition duration-100 ease-in`}
>
{text}
</a>
</Link>
)
) : (
<a
{...props}
@ -60,10 +67,20 @@ const Tag = ({
? 'font-bg bg-discord-blurple text-white'
: github
? 'bg-gray-900 text-white hover:bg-gray-700'
: `bg-little-white-hover dark:bg-very-black ${props.onClick ? 'hover:bg-little-white dark:hover:bg-discord-dark-hover transition duration-100 ease-in' : '' }`
: `bg-little-white dark:bg-discord-black ${props.onClick ? 'hover:bg-little-white-hover dark:hover:bg-discord-dark-hover transition duration-100 ease-in' : '' }`
: `bg-little-white-hover dark:bg-very-black ${
props.onClick
? 'hover:bg-little-white dark:hover:bg-discord-dark-hover transition duration-100 ease-in'
: ''
}`
: `bg-little-white dark:bg-discord-black ${
props.onClick
? 'hover:bg-little-white-hover dark:hover:bg-discord-dark-hover transition duration-100 ease-in'
: ''
}`
} ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${
circular ? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}` : `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
circular
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
} mr-1 mb-${marginBottom}`}
>
{text}

View File

@ -1,13 +1,28 @@
const Toggle = ({ checked, onChange }:ToggleProps):JSX.Element => {
return <button className='relative inline-block w-10 mr-2 align-middle select-none outline-none' onClick={onChange} onKeyPress={onChange}>
<input type='checkbox' checked={checked} className='absolute block w-6 h-6 rounded-full bg-white border-4 border-transparent appearance-none cursor-pointer outline-none checked:right-0' readOnly />
<span className={`block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer ${checked ? 'bg-koreanbots-blue' : ''}`}></span>
</button>
const Toggle = ({ checked, onChange }: ToggleProps): JSX.Element => {
return (
<button
className='relative inline-block align-middle mr-2 w-10 outline-none select-none'
onClick={onChange}
onKeyPress={onChange}
>
<input
type='checkbox'
checked={checked}
className='absolute checked:right-0 block w-6 h-6 bg-white border-4 border-transparent rounded-full outline-none appearance-none cursor-pointer'
readOnly
/>
<span
className={`block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer ${
checked ? 'bg-koreanbots-blue' : ''
}`}
></span>
</button>
)
}
interface ToggleProps {
checked: boolean,
checked: boolean
onChange(): void
}
export default Toggle
export default Toggle

View File

@ -1,43 +1,115 @@
import Link from 'next/link'
const Tooltip = ({ href, size='small', children, direction='center', text }:TooltipProps):JSX.Element => {
return href ? <Link href={href}>
const Tooltip = ({
href,
size = 'small',
children,
direction = 'center',
text,
}: TooltipProps): JSX.Element => {
return href ? (
<Link href={href}>
<a className='inline'>
<div className='relative inline py-3'>
<div className='group relative inline-block text-center cursor-pointer'>
{children}
<div
className={`opacity-0 ${
size === 'small' ? 'w-44' : 'w-60'
} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`}
>
{text}
{direction === 'left' ? (
<svg
className='absolute left-5 top-full mr-3 h-2 text-black'
x='0px'
y='0px'
viewBox='0 0 255 255'
xmlSpace='preserve'
>
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
</svg>
) : direction === 'center' ? (
<svg
className='absolute left-0 top-full w-full h-2 text-black'
x='0px'
y='0px'
viewBox='0 0 255 255'
xmlSpace='preserve'
>
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
</svg>
) : (
<svg
className='absolute right-5 top-full mr-3 h-2 text-black'
x='0px'
y='0px'
viewBox='0 0 255 255'
xmlSpace='preserve'
>
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
</svg>
)}
</div>
</div>
</div>
</a>
</Link>
) : (
<a className='inline'>
<div className='relative py-3 inline'>
<div className='group cursor-pointer relative inline-block text-center'>{children}
<div className={`opacity-0 ${size==='small' ? 'w-44' : 'w-60'} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`}>
<div className='relative inline py-3'>
<div className='group relative inline-block text-center cursor-pointer'>
{children}
<div
className={`opacity-0 ${
size === 'small' ? 'w-44' : 'w-60'
} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`}
>
{text}
{
direction === 'left' ? <svg className='absolute text-black h-2 left-5 mr-3 top-full' x='0px' y='0px' viewBox='0 0 255 255' xmlSpace='preserve'><polygon className='fill-current' points='0,0 127.5,127.5 255,0'/></svg>
: direction === 'center' ? <svg className='absolute text-black h-2 w-full left-0 top-full' x='0px' y='0px' viewBox='0 0 255 255' xmlSpace='preserve'><polygon className='fill-current' points='0,0 127.5,127.5 255,0'/></svg>
: <svg className='absolute text-black h-2 right-5 mr-3 top-full' x='0px' y='0px' viewBox='0 0 255 255' xmlSpace='preserve'><polygon className='fill-current' points='0,0 127.5,127.5 255,0'/></svg>
}
{direction === 'left' ? (
<svg
className='absolute left-5 top-full mr-3 h-2 text-black'
x='0px'
y='0px'
viewBox='0 0 255 255'
xmlSpace='preserve'
>
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
</svg>
) : direction === 'center' ? (
<svg
className='absolute left-0 top-full w-full h-2 text-black'
x='0px'
y='0px'
viewBox='0 0 255 255'
xmlSpace='preserve'
>
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
</svg>
) : (
<svg
className='absolute right-5 top-full mr-3 h-2 text-black'
x='0px'
y='0px'
viewBox='0 0 255 255'
xmlSpace='preserve'
>
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
</svg>
)}
</div>
</div>
</div>
</a>
</Link> : <a className='inline'>
<div className='relative py-3 inline'>
<div className='group cursor-pointer relative inline-block text-center'>{children}
<div className={`opacity-0 ${size==='small' ? 'w-44' : 'w-60'} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`}>
{text}
{
direction === 'left' ? <svg className='absolute text-black h-2 left-5 mr-3 top-full' x='0px' y='0px' viewBox='0 0 255 255' xmlSpace='preserve'><polygon className='fill-current' points='0,0 127.5,127.5 255,0'/></svg>
: direction === 'center' ? <svg className='absolute text-black h-2 w-full left-0 top-full' x='0px' y='0px' viewBox='0 0 255 255' xmlSpace='preserve'><polygon className='fill-current' points='0,0 127.5,127.5 255,0'/></svg>
: <svg className='absolute text-black h-2 right-5 mr-3 top-full' x='0px' y='0px' viewBox='0 0 255 255' xmlSpace='preserve'><polygon className='fill-current' points='0,0 127.5,127.5 255,0'/></svg>
}
</div>
</div>
</div>
</a>
)
}
interface TooltipProps {
href?: string
size?: 'small' | 'large'
direction?: 'left' | 'center' | 'right'
text: string
children: JSX.Element | JSX.Element[]
href?: string
size?: 'small' | 'large'
direction?: 'left' | 'center' | 'right'
text: string
children: JSX.Element | JSX.Element[]
}
export default Tooltip
export default Tooltip

View File

@ -1,4 +1,4 @@
version: "3"
version: '3'
services:
mysql:
@ -24,4 +24,4 @@ services:
deploy:
resources:
limits:
memory: 500M
memory: 500M

View File

@ -1,13 +1,14 @@
module.exports = {
apps : [{
name: 'koreanbots',
script: 'npm start',
env: {
NODE_ENV: 'development',
apps: [
{
name: 'koreanbots',
script: 'npm start',
env: {
NODE_ENV: 'development',
},
env_production: {
NODE_ENV: 'production',
},
},
env_production: {
NODE_ENV: 'production',
}
}]
}
],
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,6 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'@types': '<rootDir>/types'
}
}
'@types': '<rootDir>/types',
},
}

View File

@ -1,86 +1,88 @@
{
"name": "client-next",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start | (sleep 1; wget http://localhost:3000/api -O /dev/null)",
"lint": "eslint --ext ts,tsx .",
"lint:fix": "eslint --ext ts,tsx . --fix",
"test": "jest",
"docker": "docker-compose up -d --build"
},
"dependencies": {
"@fortawesome/fontawesome-free": "5.15.2",
"@sentry/browser": "6.2.0",
"@sentry/integrations": "6.2.0",
"@sentry/node": "6.2.0",
"@sentry/webpack-plugin": "1.14.1",
"autoprefixer": "10.2.4",
"badgen": "3.2.2",
"cookie": "0.4.1",
"core-js": "3.9.0",
"csrf": "3.1.0",
"dataloader": "2.0.0",
"dayjs": "1.10.4",
"discord.js": "12.5.1",
"emoji-mart": "3.0.1",
"formik": "2.2.6",
"generate-license-file": "1.1.0",
"josa": "3.0.1",
"jsonwebtoken": "8.5.1",
"knex": "0.21.18",
"mysql": "2.18.1",
"next": "10.0.6",
"next-connect": "0.10.0",
"next-session": "3.4.0",
"node-emoji": "1.10.0",
"postcss": "8.2.6",
"postcss-preset-env": "6.7.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-responsive-modal": "6.0.1",
"react-select": "4.1.0",
"react-showdown": "2.1.0",
"react-sortable-hoc": "1.11.0",
"react-use-clipboard": "1.0.7",
"sanitize-html": "2.3.2",
"tailwindcss": "2.0.3",
"tlru": "1.0.2",
"twemoji": "13.0.1",
"url-regex-safe": "2.0.2",
"yup": "0.32.9",
"yup-locales-ko": "1.0.2"
},
"devDependencies": {
"@types/cookie": "0.4.0",
"@types/core-js": "2.5.4",
"@types/emoji-mart": "3.0.4",
"@types/jest": "26.0.20",
"@types/josa": "3.0.2",
"@types/jsonwebtoken": "8.5.0",
"@types/node": "14.14.31",
"@types/node-emoji": "1.8.1",
"@types/node-fetch": "2.5.8",
"@types/react": "17.0.2",
"@types/react-select": "4.0.13",
"@types/sanitize-html": "1.27.1",
"@types/twemoji": "12.1.1",
"@types/url-regex-safe": "1.0.0",
"@typescript-eslint/eslint-plugin": "4.15.2",
"@typescript-eslint/parser": "4.15.2",
"eslint": "7.20.0",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-jsx-a11y": "6.4.1",
"eslint-plugin-prettier": "3.3.1",
"eslint-plugin-react": "7.22.0",
"eslint-plugin-react-hooks": "4.2.0",
"jest": "26.6.3",
"prettier": "2.2.1",
"prettier-plugin-tailwind": "2.2.9",
"ts-jest": "26.5.2",
"typescript": "4.2.2"
},
"license": "AGPL-3.0"
"name": "client-next",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start | (sleep 1; wget http://localhost:3000/api -O /dev/null)",
"lint": "eslint --ext ts,tsx .",
"prettier": "prettier --write **/*",
"lint:fix": "eslint --ext ts,tsx . --fix",
"test": "jest",
"docker": "docker-compose up -d --build",
"postinstall": "husky install"
},
"dependencies": {
"@fortawesome/fontawesome-free": "5.15.2",
"@sentry/browser": "6.2.0",
"@sentry/integrations": "6.2.0",
"@sentry/node": "6.2.0",
"@sentry/webpack-plugin": "1.14.1",
"autoprefixer": "10.2.4",
"badgen": "3.2.2",
"cookie": "0.4.1",
"core-js": "3.9.0",
"csrf": "3.1.0",
"dataloader": "2.0.0",
"dayjs": "1.10.4",
"discord.js": "12.5.1",
"emoji-mart": "3.0.1",
"formik": "2.2.6",
"generate-license-file": "1.1.0",
"josa": "3.0.1",
"jsonwebtoken": "8.5.1",
"knex": "0.21.18",
"mysql": "2.18.1",
"next": "10.0.6",
"next-connect": "0.10.0",
"next-session": "3.4.0",
"node-emoji": "1.10.0",
"postcss": "8.2.6",
"postcss-preset-env": "6.7.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-responsive-modal": "6.0.1",
"react-select": "4.1.0",
"react-showdown": "2.1.0",
"react-sortable-hoc": "1.11.0",
"react-use-clipboard": "1.0.7",
"sanitize-html": "2.3.2",
"tailwindcss": "2.0.3",
"tlru": "1.0.2",
"twemoji": "13.0.1",
"url-regex-safe": "2.0.2",
"yup": "0.32.9",
"yup-locales-ko": "1.0.2"
},
"devDependencies": {
"@types/cookie": "0.4.0",
"@types/core-js": "2.5.4",
"@types/emoji-mart": "3.0.4",
"@types/jest": "26.0.20",
"@types/josa": "3.0.2",
"@types/jsonwebtoken": "8.5.0",
"@types/node": "14.14.31",
"@types/node-emoji": "1.8.1",
"@types/node-fetch": "2.5.8",
"@types/react": "17.0.2",
"@types/react-select": "4.0.13",
"@types/sanitize-html": "1.27.1",
"@types/twemoji": "12.1.1",
"@types/url-regex-safe": "1.0.0",
"@typescript-eslint/eslint-plugin": "4.15.2",
"@typescript-eslint/parser": "4.15.2",
"eslint": "7.20.0",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-jsx-a11y": "6.4.1",
"eslint-plugin-prettier": "3.3.1",
"eslint-plugin-react": "7.22.0",
"eslint-plugin-react-hooks": "4.2.0",
"jest": "26.6.3",
"prettier": "2.2.1",
"prettier-plugin-tailwind": "2.2.9",
"ts-jest": "26.5.2",
"typescript": "4.2.2"
},
"license": "AGPL-3.0"
}

View File

@ -2,15 +2,25 @@ import { NextPage } from 'next'
import { ErrorText } from '@utils/Constants'
const NotFound: NextPage = () => {
return <div className='h-screen flex md:flex-col items-center justify-center' style={{ background: 'url("https://cdn.discordapp.com/attachments/745844596176715806/799149423505440768/1590927393326.jpg")' }}>
<div className='text-center'>
<h1 className='w-inline-block text-4xl font-bold align-top border-none md:border-r md:border-solid md:border-current m-0 md:mr-10 py-10 md:pr-10'>404</h1>
return (
<div
className='flex items-center justify-center h-screen md:flex-col'
style={{
background:
'url("https://cdn.discordapp.com/attachments/745844596176715806/799149423505440768/1590927393326.jpg")',
}}
>
<div className='text-center'>
<h1 className='w-inline-block align-top m-0 py-10 text-4xl font-bold border-none md:mr-10 md:pr-10 md:border-r md:border-solid md:border-current'>
404
</h1>
<h2 className='inline-block text-2xl md:text-4xl font-semibold align-top m-0 py-10'>
{ ErrorText[404] }
</h2>
<h2 className='inline-block align-top m-0 py-10 text-2xl font-semibold md:text-4xl'>
{ErrorText[404]}
</h2>
</div>
</div>
</div>
)
}
export default NotFound

View File

@ -10,8 +10,10 @@ class MyDocument extends Document {
return (
<Html>
<Head>
<link rel='stylesheet'
href='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/styles/default.min.css'/>
<link
rel='stylesheet'
href='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/styles/default.min.css'
/>
<script src='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/highlight.min.js'></script>
<script
data-ad-client='ca-pub-4856582423981759'
@ -38,7 +40,7 @@ class MyDocument extends Document {
}}
/>
</Head>
<body className='h-full overflow-x-hidden text-black dark:text-gray-100 dark:bg-discord-dark bg-white'>
<body className='h-full text-black dark:text-gray-100 dark:bg-discord-dark bg-white overflow-x-hidden'>
<Main />
<NextScript />
</body>

View File

@ -1,9 +1,8 @@
import ResponseWrapper from '@utils/ResponseWrapper'
import RequestHandler from '@utils/RequestHandler'
const NotFound = RequestHandler()
.all(async(_req, res) => {
return ResponseWrapper(res, { code: 404, message: '요청하신 URL에 페이지가 존재하지 않습니다.' })
})
const NotFound = RequestHandler().all(async (_req, res) => {
return ResponseWrapper(res, { code: 404, message: '요청하신 URL에 페이지가 존재하지 않습니다.' })
})
export default NotFound

View File

@ -3,7 +3,11 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
const RateLimit: NextApiHandler = (_req: NextApiRequest, res: NextApiResponse) => {
res.statusCode = 429
return ResponseWrapper(res, { code: 429, message: '지정된 시간에 너무 많은 요청을 보냈습니다. 잠시 뒤에 시도해주세요.', errors: ['지정된 시간에 너무 많은 요청을 보냈습니다. 잠시 뒤에 시도해주세요.'] })
return ResponseWrapper(res, {
code: 429,
message: '지정된 시간에 너무 많은 요청을 보냈습니다. 잠시 뒤에 시도해주세요.',
errors: ['지정된 시간에 너무 많은 요청을 보냈습니다. 잠시 뒤에 시도해주세요.'],
})
}
export default RateLimit

View File

@ -11,51 +11,62 @@ import { update } from '@utils/Query'
import { verify } from '@utils/Jwt'
import RequestHandler from '@utils/RequestHandler'
const Callback = RequestHandler()
.get(async(req: ApiRequest, res) => {
const validate = await OauthCallbackSchema.validate(req.query).then(r=> r).catch((e) => {
const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
const validate = await OauthCallbackSchema.validate(req.query)
.then(r => r)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!validate) return
res.statusCode = 200
const token:DiscordTokenInfo = await fetch(DiscordEnpoints.Token, {
method: 'POST',
body: formData({
client_id: process.env.DISCORD_CLIENT_ID,
redirect_uri: process.env.KOREANBOTS_URL + '/api/auth/discord/callback',
client_secret: process.env.DISCORD_CLIENT_SECRET,
scope: process.env.DISCORD_SCOPE,
grant_type: 'authorization_code',
code: req.query.code
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).then(r=> r.json())
if(token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] })
if (!validate) return
const user:DiscordUserInfo = await fetch(DiscordEnpoints.Me, {
method: 'GET',
headers: {
Authorization: `${token.token_type} ${token.access_token}`
}
}).then(r => r.json())
res.statusCode = 200
const token: DiscordTokenInfo = await fetch(DiscordEnpoints.Token, {
method: 'POST',
body: formData({
client_id: process.env.DISCORD_CLIENT_ID,
redirect_uri: process.env.KOREANBOTS_URL + '/api/auth/discord/callback',
client_secret: process.env.DISCORD_CLIENT_SECRET,
scope: process.env.DISCORD_SCOPE,
grant_type: 'authorization_code',
code: req.query.code,
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}).then(r => r.json())
if (token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] })
const userToken = await update.assignToken({ id: user.id, access_token: token.access_token, expires_in: token.expires_in, refresh_token: token.refresh_token, email: user.email, username: user.username, discriminator: user.discriminator })
const info = verify(userToken)
res.setHeader('set-cookie', serialize('token', userToken, {
const user: DiscordUserInfo = await fetch(DiscordEnpoints.Me, {
method: 'GET',
headers: {
Authorization: `${token.token_type} ${token.access_token}`,
},
}).then(r => r.json())
const userToken = await update.assignToken({
id: user.id,
access_token: token.access_token,
expires_in: token.expires_in,
refresh_token: token.refresh_token,
email: user.email,
username: user.username,
discriminator: user.discriminator,
})
const info = verify(userToken)
res.setHeader(
'set-cookie',
serialize('token', userToken, {
expires: new Date(info.exp * 1000),
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
path: '/'
}))
res.redirect(301, '/callback/discord')
})
path: '/',
})
)
res.redirect(301, '/callback/discord')
})
interface ApiRequest extends NextApiRequest {
query: {

View File

@ -2,9 +2,11 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { generateOauthURL } from '@utils/Tools'
import RequestHandler from '@utils/RequestHandler'
const Discord = RequestHandler()
.get(async (_req: NextApiRequest, res: NextApiResponse) => {
res.redirect(301, generateOauthURL('discord', process.env.DISCORD_CLIENT_ID, process.env.DISCORD_SCOPE))
})
const Discord = RequestHandler().get(async (_req: NextApiRequest, res: NextApiResponse) => {
res.redirect(
301,
generateOauthURL('discord', process.env.DISCORD_CLIENT_ID, process.env.DISCORD_SCOPE)
)
})
export default Discord

View File

@ -1,14 +1,15 @@
import { serialize } from 'cookie'
import RequestHandler from '@utils/RequestHandler'
const Logout = RequestHandler()
.get(async(req, res) => {
res.setHeader('Cache-control', 'no-cache')
res.setHeader('set-cookie', serialize('token', '', {
const Logout = RequestHandler().get(async (req, res) => {
res.setHeader('Cache-control', 'no-cache')
res.setHeader(
'set-cookie',
serialize('token', '', {
maxAge: -1,
path: '/'
}))
res.redirect(301, '/')
})
export default Logout
path: '/',
})
)
res.redirect(301, '/')
})
export default Logout

View File

@ -1,13 +1,12 @@
import ResponseWrapper from '@utils/ResponseWrapper'
import RequestHandler from '@utils/RequestHandler'
const Deprecated = RequestHandler()
.get(async (_req, res) => {
return ResponseWrapper(res, {
code: 406,
message: '해당 API 버전은 지원 종료되었습니다.',
version: 1,
})
const Deprecated = RequestHandler().get(async (_req, res) => {
return ResponseWrapper(res, {
code: 406,
message: '해당 API 버전은 지원 종료되었습니다.',
version: 1,
})
})
export default Deprecated

View File

@ -8,25 +8,25 @@ import RequestHandler from '@utils/RequestHandler'
import { User } from '@types'
const BotApplications = RequestHandler()
.patch(async (req: ApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if(!csrfValidated) return
const validated = await DeveloperBotSchema.validate(req.body, { abortEarly: false }).then(el => el).catch(e => {
const BotApplications = RequestHandler().patch(async (req: ApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const validated = await DeveloperBotSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!validated) return
const bot = await get.bot.load(req.query.id)
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if(!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
await update.updateBotApplication(req.query.id, { webhook: validated.webhook || null })
return ResponseWrapper(res, { code: 200 })
})
if (!validated) return
const bot = await get.bot.load(req.query.id)
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if (!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
await update.updateBotApplication(req.query.id, { webhook: validated.webhook || null })
return ResponseWrapper(res, { code: 200 })
})
interface ApiRequest extends NextApiRequest {
body: DeveloperBot
@ -35,4 +35,4 @@ interface ApiRequest extends NextApiRequest {
}
}
export default BotApplications
export default BotApplications

View File

@ -8,31 +8,32 @@ import RequestHandler from '@utils/RequestHandler'
import { User } from '@types'
const ResetApplication = RequestHandler()
.post(async (req: ApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if(!csrfValidated) return
const validated = await ResetBotTokenSchema.validate(req.body, { abortEarly: false }).then(el => el).catch(e => {
const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const validated = await ResetBotTokenSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!validated) return
const bot = await get.bot.load(req.query.id)
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if(!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
const d = await update.resetBotToken(req.query.id, validated.token)
if(!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' })
return ResponseWrapper(res, { code: 200, data: { token: d }})
})
if (!validated) return
const bot = await get.bot.load(req.query.id)
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if (!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
const d = await update.resetBotToken(req.query.id, validated.token)
if (!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' })
return ResponseWrapper(res, { code: 200, data: { token: d } })
})
interface ApiRequest extends NextApiRequest {
body: ResetBotToken
query: {
id: string
}
}
interface ApiRequest extends NextApiRequest {
body: ResetBotToken
query: {
id: string
}
}
export default ResetApplication
export default ResetApplication

View File

@ -7,29 +7,56 @@ import { AddBotSubmit, AddBotSubmitSchema } from '@utils/Yup'
import RequestHandler from '@utils/RequestHandler'
const Bots = RequestHandler()
.get(async(req: GetApiRequest, res) => {
.get(async (req: GetApiRequest, res) => {
const bot = await get.bot.load(req.query.id)
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
else return ResponseWrapper(res, { code: 200, data: bot })
})
.post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 })
if (!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if(!csrfValidated) return
if (!csrfValidated) return
const validated = await AddBotSubmitSchema.validate(req.body, { abortEarly: false }).then(el => el).catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
const validated = await AddBotSubmitSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!validated) return
if(validated.id !== req.query.id) return ResponseWrapper(res, { code: 400, errors: ['요청 주소와 Body의 정보가 다릅니다.'] })
if (!validated) return
if (validated.id !== req.query.id)
return ResponseWrapper(res, { code: 400, errors: ['요청 주소와 Body의 정보가 다릅니다.'] })
const result = await put.submitBot(user, validated)
if(result === 1) return ResponseWrapper(res, { code: 403, message: '이미 대기중인 봇이 있습니다.', errors: ['한 번에 최대 2개의 봇까지만 신청하실 수 있습니다.\n다른 봇들의 심사가 완료된 뒤에 신청해주세요.'] })
else if(result === 2) return ResponseWrapper(res, { code: 406, message: '해당 봇은 이미 심사중이거나 이미 등록되어있습니다.', errors: ['해당 아이디의 봇은 이미 심사중이거나 등록되어있습니다. 본인 소유의 봇이고 신청하신 적이 없으시다면 문의해주세요.'] })
else if(result === 3) return ResponseWrapper(res, { code: 404, message: '올바르지 않은 봇 아이디입니다.', errors: ['해당 아이디의 봇은 존재하지 않습니다. 다시 확인해주세요.'] })
else if(result === 4) return ResponseWrapper(res, { code: 403, message: '디스코드 서버에 참가해주세요.' , errors: ['봇 신청하시기 위해서는 공식 디스코드 서버에 참가해주셔야합니다.'] })
if (result === 1)
return ResponseWrapper(res, {
code: 403,
message: '이미 대기중인 봇이 있습니다.',
errors: [
'한 번에 최대 2개의 봇까지만 신청하실 수 있습니다.\n다른 봇들의 심사가 완료된 뒤에 신청해주세요.',
],
})
else if (result === 2)
return ResponseWrapper(res, {
code: 406,
message: '해당 봇은 이미 심사중이거나 이미 등록되어있습니다.',
errors: [
'해당 아이디의 봇은 이미 심사중이거나 등록되어있습니다. 본인 소유의 봇이고 신청하신 적이 없으시다면 문의해주세요.',
],
})
else if (result === 3)
return ResponseWrapper(res, {
code: 404,
message: '올바르지 않은 봇 아이디입니다.',
errors: ['해당 아이디의 봇은 존재하지 않습니다. 다시 확인해주세요.'],
})
else if (result === 4)
return ResponseWrapper(res, {
code: 403,
message: '디스코드 서버에 참가해주세요.',
errors: ['봇 신청하시기 위해서는 공식 디스코드 서버에 참가해주셔야합니다.'],
})
return ResponseWrapper(res, { code: 200, data: result })
})
.patch(async (req, res) => {
@ -49,4 +76,4 @@ interface PostApiRequest extends GetApiRequest {
}
}
export default Bots
export default Bots

View File

@ -7,28 +7,32 @@ import { SearchQuerySchema } from '@utils/Yup'
import { BotList } from '@types'
const SearchBots = RequestHandler()
.get(async (req: ApiRequest, res: NextApiResponse) => {
const validated = await SearchQuerySchema.validate({ q: req.query.q, page: req.query.page }).then(el => el).catch(e => {
const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => {
const validated = await SearchQuerySchema.validate({ q: req.query.q, page: req.query.page })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
})
if(!validated) return
if (!validated) return
let result: BotList
try {
result = await get.list.search.load(JSON.stringify({ page: validated.page, query: validated.q }))
} catch {
return ResponseWrapper(res, { code: 400, message: '검색 문법이 잘못되었습니다.' })
}
if(result.totalPage < validated.page || result.currentPage !== validated.page) return ResponseWrapper(res, { code: 404, message: '검색 결과가 없습니다.' })
else ResponseWrapper<BotList>(res, { code: 200, data: result })
})
let result: BotList
try {
result = await get.list.search.load(
JSON.stringify({ page: validated.page, query: validated.q })
)
} catch {
return ResponseWrapper(res, { code: 400, message: '검색 문법이 잘못되었습니다.' })
}
if (result.totalPage < validated.page || result.currentPage !== validated.page)
return ResponseWrapper(res, { code: 404, message: '검색 결과가 없습니다.' })
else ResponseWrapper<BotList>(res, { code: 200, data: result })
})
interface ApiRequest extends NextApiRequest {
query: {
q: string
page: string
}
query: {
q: string
page: string
}
}
export default SearchBots
export default SearchBots

View File

@ -4,13 +4,12 @@ import { get } from '@utils/Query'
import ResponseWrapper from '@utils/ResponseWrapper'
import RequestHandler from '@utils/RequestHandler'
const Users = RequestHandler()
.get(async(req: ApiRequest, res) => {
console.log(req.query)
const user = await get.user.load(req.query?.id)
if(!user) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 유저 입니다.' })
else return ResponseWrapper(res, { code: 200, data: user })
})
const Users = RequestHandler().get(async (req: ApiRequest, res) => {
console.log(req.query)
const user = await get.user.load(req.query?.id)
if (!user) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 유저 입니다.' })
else return ResponseWrapper(res, { code: 200, data: user })
})
interface ApiRequest extends NextApiRequest {
query: {
@ -18,4 +17,4 @@ interface ApiRequest extends NextApiRequest {
}
}
export default Users
export default Users

View File

@ -8,41 +8,49 @@ import { get } from '@utils/Query'
import { BotBadgeType, DiscordEnpoints } from '@utils/Constants'
import RequestHandler from '@utils/RequestHandler'
const Widget= RequestHandler()
.get(async(req: ApiRequest, res: NextApiResponse) => {
const { id: param, type, style='flat', scale=1, icon=true } = req.query
const splitted = param.split('.')
const Widget = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => {
const { id: param, type, style = 'flat', scale = 1, icon = true } = req.query
const splitted = param.split('.')
const validated = await WidgetOptionsSchema.validate({
id: splitted.slice(0, splitted.length - 1).join('.'),
ext: splitted[splitted.length - 1],
style,
type,
scale,
icon
}).then(el=> el).catch(e=> {
const validated = await WidgetOptionsSchema.validate({
id: splitted.slice(0, splitted.length - 1).join('.'),
ext: splitted[splitted.length - 1],
style,
type,
scale,
icon,
})
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!validated) return
const data = await get.bot.load(validated.id)
if(!data) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
const userImage = !data.avatar ? null : await get.images.user.load(DiscordEnpoints.CDN.user(data.id, data.avatar, { format: 'png', size: 128 }))
const img = userImage || await get.images.user.load(DiscordEnpoints.CDN.default(data.tag, { format: 'png', size: 128 }))
res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
const badgeData = {
...BotBadgeType(data)[type],
style: validated.style,
scale: validated.scale,
icon: validated.icon ? `data:image/png;base64,${img.toString('base64')}` : null
}
if (!validated) return
res.send(badgen(badgeData))
})
const data = await get.bot.load(validated.id)
if (!data) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
const userImage = !data.avatar
? null
: await get.images.user.load(
DiscordEnpoints.CDN.user(data.id, data.avatar, { format: 'png', size: 128 })
)
const img =
userImage ||
(await get.images.user.load(
DiscordEnpoints.CDN.default(data.tag, { format: 'png', size: 128 })
))
res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
const badgeData = {
...BotBadgeType(data)[type],
style: validated.style,
scale: validated.scale,
icon: validated.icon ? `data:image/png;base64,${img.toString('base64')}` : null,
}
res.send(badgen(badgeData))
})
interface ApiRequest extends NextApiRequest {
query: {
@ -54,4 +62,4 @@ interface ApiRequest extends NextApiRequest {
}
}
export default Widget
export default Widget

View File

@ -21,7 +21,7 @@ const Segment = dynamic(() => import('@components/Segment'))
const SEO = dynamic(() => import('@components/SEO'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const VoteBot: NextPage<VoteBotProps> = ({ data, user, csrfToken }) => {
const VoteBot: NextPage<VoteBotProps> = ({ data, csrfToken }) => {
console.log(csrfToken)
const router = useRouter()
if(!data?.id) return <NotFound />

View File

@ -1,10 +1,10 @@
import { NextPage } from 'next'
import { useRouter } from 'next/router'
const Reserved:NextPage = () => {
const Reserved: NextPage = () => {
const router = useRouter()
router.push('/bots/iu')
return <></>
}
export default Reserved
export default Reserved

View File

@ -7,4 +7,4 @@ const Developers: NextPage = () => {
return <></>
}
export default Developers
export default Developers

View File

@ -14,4 +14,4 @@ module.exports = {
},
],
],
}
}

View File

@ -1,11 +1,7 @@
{
"labels": ["meta: dependencies"],
"reviewers": ["team:koreanbots-devs"],
"schedule": [
"before 8am"
],
"extends": [
"config:base"
],
"timezone": "Asia/Seoul"
"labels": ["meta: dependencies"],
"reviewers": ["team:koreanbots-devs"],
"schedule": ["before 8am"],
"extends": ["config:base"],
"timezone": "Asia/Seoul"
}

View File

@ -19,7 +19,9 @@ test('checking Permission', () => {
})
test('check CDN URL', () => {
expect(DiscordEnpoints.CDN.user('000000000000000000', 'abcdefghijklm', { format: 'jpg', size: 1024 })).toBe('https://cdn.discordapp.com/avatars/000000000000000000/abcdefghijklm.jpg?size=1024')
expect(
DiscordEnpoints.CDN.user('000000000000000000', 'abcdefghijklm', { format: 'jpg', size: 1024 })
).toBe('https://cdn.discordapp.com/avatars/000000000000000000/abcdefghijklm.jpg?size=1024')
})
export { }
export {}

View File

@ -1,36 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@utils/*": ["utils/*"],
"@types": ["types/index.ts"]
},
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@utils/*": ["utils/*"],
"@types": ["types/index.ts"]
},
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

2
types/global.d.ts vendored
View File

@ -3,4 +3,4 @@ declare module 'yup' {
class ArraySchema extends Yup.array {
unique(format?: string): this
}
}
}

View File

@ -46,7 +46,7 @@ export enum UserFlags {
general = 0 << 0,
staff = 1 << 0,
bughunter = 1 << 1,
premium = 1 << 2
premium = 1 << 2,
}
export enum BotFlags {
@ -56,23 +56,23 @@ export enum BotFlags {
partnered = 1 << 3,
verifed = 1 << 4,
premium = 1 << 5,
hackerthon = 1 << 6
hackerthon = 1 << 6,
}
export enum DiscordUserFlags {
DISCORD_EMPLOYEE = 1 << 0,
DISCORD_PARTNER = 1 << 1,
HYPESQUAD_EVENTS = 1 << 2,
BUGHUNTER_LEVEL_1 = 1 << 3,
HOUSE_BRAVERY = 1 << 6,
HOUSE_BRILLIANCE = 1 << 7,
HOUSE_BALANCE = 1 << 8,
EARLY_SUPPORTER = 1 << 9,
TEAM_USER = 1 << 10,
SYSTEM = 1 << 12,
BUGHUNTER_LEVEL_2 = 1 << 14,
VERIFIED_BOT = 1 << 16,
VERIFIED_DEVELOPER = 1 << 17
DISCORD_EMPLOYEE = 1 << 0,
DISCORD_PARTNER = 1 << 1,
HYPESQUAD_EVENTS = 1 << 2,
BUGHUNTER_LEVEL_1 = 1 << 3,
HOUSE_BRAVERY = 1 << 6,
HOUSE_BRILLIANCE = 1 << 7,
HOUSE_BALANCE = 1 << 8,
EARLY_SUPPORTER = 1 << 9,
TEAM_USER = 1 << 10,
SYSTEM = 1 << 12,
BUGHUNTER_LEVEL_2 = 1 << 14,
VERIFIED_BOT = 1 << 16,
VERIFIED_DEVELOPER = 1 << 17,
}
export interface BotList {
@ -97,7 +97,6 @@ export interface SubmittedBot {
discord: string | null
state: number
reason: string | null
}
export interface DiscordTokenInfo {
@ -215,7 +214,7 @@ export enum DiscordImageType {
EMOJI = 'emoji',
GUILD = 'guild',
USER = 'user',
FALLBACK = 'default'
FALLBACK = 'default',
}
export interface CsrfContext extends NextPageContext {
@ -226,7 +225,7 @@ export interface CsrfRequestMessage extends IncomingMessage {
csrfToken(): string
}
export interface ResponseProps<T=Data> {
export interface ResponseProps<T = Data> {
code?: number
message?: string
version?: number
@ -234,6 +233,6 @@ export interface ResponseProps<T=Data> {
errors?: string[]
}
interface Data<T=unknown> {
interface Data<T = unknown> {
[key: string]: T
}

View File

@ -9,20 +9,23 @@ const csrfKey = '_csrf'
const Token = new csrf()
export const tokenCreate = ():string => Token.create(process.env.CSRF_SECRET)
export const tokenCreate = (): string => Token.create(process.env.CSRF_SECRET)
export const tokenVerify = (token: string):boolean => Token.verify(process.env.CSRF_SECRET, token)
export const tokenVerify = (token: string): boolean => Token.verify(process.env.CSRF_SECRET, token)
export const getToken = (req: IncomingMessage, res: ServerResponse) => {
const parsed = parse(req.headers.cookie || '')
let key:string = parsed[csrfKey]
if(!key || !tokenVerify(key)) {
let key: string = parsed[csrfKey]
if (!key || !tokenVerify(key)) {
key = tokenCreate()
res.setHeader('set-cookie', serialize(csrfKey, key, {
expires: new Date(+new Date() + 24 * 60 * 60 * 1000),
httpOnly: true,
path: '/'
}))
res.setHeader(
'set-cookie',
serialize(csrfKey, key, {
expires: new Date(+new Date() + 24 * 60 * 60 * 1000),
httpOnly: true,
path: '/',
})
)
}
return key
@ -30,8 +33,8 @@ export const getToken = (req: IncomingMessage, res: ServerResponse) => {
export const checkToken = (req: NextApiRequest, res: NextApiResponse, token: string): boolean => {
const parsed = parse(req.headers.cookie || '')
if(parsed[csrfKey] !== token || !tokenVerify(token)) {
if (parsed[csrfKey] !== token || !tokenVerify(token)) {
ResponseWrapper(res, { code: 400, message: 'CSRF 검증 에러 (페이지를 새로고침해주세요)' })
return false
} else return true
}
}

View File

@ -12,4 +12,4 @@ DiscordBot.on('ready', async () => {
DiscordBot.login(process.env.DISCORD_TOKEN)
const getMainGuild = () => DiscordBot.guilds.cache.get(guildID)
export { DiscordBot, getMainGuild }
export { DiscordBot, getMainGuild }

View File

@ -1,11 +1,15 @@
import { ResponseProps } from '@types'
import { KoreanbotsEndPoints } from './Constants'
const Fetch = async <T>(endpoint: string, options?: RequestInit):Promise<ResponseProps<T>> => {
const url = KoreanbotsEndPoints.baseAPI + ( endpoint.startsWith('/') ? endpoint : '/' + endpoint)
const res = await fetch(url, { method: 'GET', headers: { 'content-type': 'application/json', ...options.headers }, ...options })
const Fetch = async <T>(endpoint: string, options?: RequestInit): Promise<ResponseProps<T>> => {
const url = KoreanbotsEndPoints.baseAPI + (endpoint.startsWith('/') ? endpoint : '/' + endpoint)
const res = await fetch(url, {
method: 'GET',
headers: { 'content-type': 'application/json', ...options.headers },
...options,
})
let json = {}
try {
@ -13,8 +17,8 @@ const Fetch = async <T>(endpoint: string, options?: RequestInit):Promise<Respons
} catch {
json = { code: 500, message: 'Internal Server Error' }
}
return json
}
export default Fetch
export default Fetch

View File

@ -7,7 +7,7 @@ export default knex({
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE || 'discordbots',
charset: 'utf8mb4'
charset: 'utf8mb4',
},
debug: process.env.NODE_ENV === 'development',
})

View File

@ -7,10 +7,10 @@ const Logger = {
},
error: function(message: string) {
print('ERROR', message, genStyle('red', 'white'))
}
},
}
function genStyle(bg: string, text='black') {
function genStyle(bg: string, text = 'black') {
return `color:${text};background:${bg};padding:1px 3px;border-radius:2px;margin-right:5px;`
}

View File

@ -1,23 +1,23 @@
import { NextApiResponse } from 'next'
import ResponseWrapper from './ResponseWrapper'
export default function RateLimitHandler(
res: NextApiResponse, ratelimit: RateLimit
) {
export default function RateLimitHandler(res: NextApiResponse, ratelimit: RateLimit) {
res.setHeader('x-ratelimit-limit', ratelimit.limit)
res.setHeader('x-ratelimit-remaining', 600 - (ratelimit.used > ratelimit.limit ? ratelimit.limit : ratelimit.used))
res.setHeader(
'x-ratelimit-remaining',
600 - (ratelimit.used > ratelimit.limit ? ratelimit.limit : ratelimit.used)
)
res.setHeader('x-ratelimit-reset', Math.round(ratelimit.reset / 1000))
if(ratelimit.limit < ratelimit.used) {
if(ratelimit.onLimitExceed) ratelimit.onLimitExceed(res)
if (ratelimit.limit < ratelimit.used) {
if (ratelimit.onLimitExceed) ratelimit.onLimitExceed(res)
else ResponseWrapper(res, { code: 429 })
return true
}
}
interface RateLimit {
used: number
limit: number
reset: number,
onLimitExceed: (res: NextApiResponse)=> void | Promise<void>
}
reset: number
onLimitExceed: (res: NextApiResponse) => void | Promise<void>
}

View File

@ -6,7 +6,8 @@ export const Prefix = /^[^\s]/
export const HTTPProtocol = /^https?:\/\/.*?/
export const Url = urlRegex({ strict: true })
export const Emoji = '(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])'
export const Emoji =
'(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])'
export const Heading = '<h\\d id="(.+?)">(.*?)<\\/h(\\d)>'
export const EmojiSyntax = ':(\\w+):'
export const ImageTag = /<img\s[^>]*?alt\s*=\s*['"]([^'"]*?)['"][^>]*?>/

View File

@ -2,14 +2,15 @@ import { NextApiRequest, NextApiResponse } from 'next'
import nc from 'next-connect'
import ResponseWrapper from '@utils/ResponseWrapper'
const RequestHandler = () => nc<NextApiRequest, NextApiResponse>({
onNoMatch(_req, res) {
return ResponseWrapper(res, { code: 405 })
},
onError(err, _req, res) {
console.error(err)
return ResponseWrapper(res, { code: 500 })
}
})
const RequestHandler = () =>
nc<NextApiRequest, NextApiResponse>({
onNoMatch(_req, res) {
return ResponseWrapper(res, { code: 405 })
},
onError(err, _req, res) {
console.error(err)
return ResponseWrapper(res, { code: 500 })
},
})
export default RequestHandler
export default RequestHandler

View File

@ -2,14 +2,19 @@ import http from 'http'
import { NextApiResponse } from 'next'
import { ResponseProps } from '@types'
import { ErrorText } from './Constants'
export default function ResponseWrapper<T=unknown>(
export default function ResponseWrapper<T = unknown>(
res: NextApiResponse,
{ code=200, message, version = 2, data, errors }: ResponseProps<T>
{ code = 200, message, version = 2, data, errors }: ResponseProps<T>
) {
if (!code) throw new Error('`code` is required.')
if (!http.STATUS_CODES[code]) throw new Error('Invalid http code.')
res.statusCode = code
res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL)
res.json({ code, data, errors, version, ...(message || !data ? { message: message || ErrorText[code] || http.STATUS_CODES[code] } : {}) })
res.json({
code,
data,
errors,
version,
...(message || !data ? { message: message || ErrorText[code] || http.STATUS_CODES[code] } : {}),
})
}

View File

@ -1,4 +1,3 @@
import { TokenExpiredError } from 'jsonwebtoken'
import * as Yup from 'yup'
import YupKorean from 'yup-locales-ko'
import { ListType } from '../types'
@ -8,17 +7,23 @@ import { HTTPProtocol, ID, Prefix, Url, Vanity } from './Regex'
Yup.setLocale(YupKorean)
Yup.addMethod(Yup.array, 'unique', function(message, mapper = a => a) {
return this.test('unique', message || 'array must be unique', function(list) {
return list.length === new Set(list.map(mapper)).size
return list.length === new Set(list.map(mapper)).size
})
})
export const botListArgumentSchema: Yup.SchemaOf<botListArgument> = Yup.object({
type: Yup.mixed().oneOf(['VOTE', 'TRUSTED', 'NEW', 'PARTNERED', 'CATEGORY', 'SEARCH']).required(),
page: Yup.number().positive().integer().notRequired().default(1),
query: Yup.string().notRequired()
type: Yup.mixed()
.oneOf(['VOTE', 'TRUSTED', 'NEW', 'PARTNERED', 'CATEGORY', 'SEARCH'])
.required(),
page: Yup.number()
.positive()
.integer()
.notRequired()
.default(1),
query: Yup.string().notRequired(),
})
export interface botListArgument {
export interface botListArgument {
type: ListType
page?: number
query?: string
@ -26,8 +31,12 @@ export interface botListArgument {
export const ImageOptionsSchema: Yup.SchemaOf<ImageOptions> = Yup.object({
id: Yup.string().required(),
ext: Yup.mixed<ext>().oneOf(['webp', 'png', 'gif']).required(),
size: Yup.mixed<ImageSize>().oneOf(['128', '256', '512']).required()
ext: Yup.mixed<ext>()
.oneOf(['webp', 'png', 'gif'])
.required(),
size: Yup.mixed<ImageSize>()
.oneOf(['128', '256', '512'])
.required(),
})
interface ImageOptions {
@ -41,11 +50,21 @@ type ImageSize = '128' | '256' | '512'
export const WidgetOptionsSchema: Yup.SchemaOf<WidgetOptions> = Yup.object({
id: Yup.string().required(),
ext: Yup.mixed<widgetExt>().oneOf(['svg']).required(),
type: Yup.mixed<widgetType>().oneOf(['votes', 'servers', 'status']).required(),
scale: Yup.number().positive().min(0.5).max(3).required(),
style: Yup.mixed<'flat'|'classic'>().oneOf(['flat', 'classic']).default('flat'),
icon: Yup.boolean().default(true)
ext: Yup.mixed<widgetExt>()
.oneOf(['svg'])
.required(),
type: Yup.mixed<widgetType>()
.oneOf(['votes', 'servers', 'status'])
.required(),
scale: Yup.number()
.positive()
.min(0.5)
.max(3)
.required(),
style: Yup.mixed<'flat' | 'classic'>()
.oneOf(['flat', 'classic'])
.default('flat'),
icon: Yup.boolean().default(true),
})
interface WidgetOptions {
@ -60,15 +79,20 @@ interface WidgetOptions {
type widgetType = 'votes' | 'servers' | 'status'
type widgetExt = 'svg'
export const PageCount = Yup.number().integer().positive().required()
export const PageCount = Yup.number()
.integer()
.positive()
.required()
export const OauthCallbackSchema: Yup.SchemaOf<OauthCallback> = Yup.object({
code: Yup.string().required()
code: Yup.string().required(),
})
export const botCategoryListArgumentSchema: Yup.SchemaOf<botCategoryListArgument> = Yup.object({
page: PageCount,
category: Yup.mixed().oneOf(categories).required()
category: Yup.mixed()
.oneOf(categories)
.required(),
})
interface botCategoryListArgument {
@ -81,8 +105,17 @@ interface OauthCallback {
}
export const SearchQuerySchema: Yup.SchemaOf<SearchQuery> = Yup.object({
q: Yup.string().min(2, '최소 2글자 이상 입력해주세요.').max(50).required('검색어를 입력해주세요.').label('검색어'),
page: Yup.number().positive().integer().notRequired().default(1).label('페이지')
q: Yup.string()
.min(2, '최소 2글자 이상 입력해주세요.')
.max(50)
.required('검색어를 입력해주세요.')
.label('검색어'),
page: Yup.number()
.positive()
.integer()
.notRequired()
.default(1)
.label('페이지'),
})
interface SearchQuery {
@ -91,18 +124,49 @@ interface SearchQuery {
}
export const AddBotSubmitSchema: Yup.SchemaOf<AddBotSubmit> = Yup.object({
agree: Yup.boolean().oneOf([true], '상단의 체크박스를 클릭해주세요.').required('상단의 체크박스를 클릭해주세요.'),
id: Yup.string().matches(ID, '올바른 봇 ID를 입력해주세요.').required('봇 ID는 필수 항목입니다.'),
prefix: Yup.string().matches(Prefix, '접두사는 띄어쓰기로 시작할 수 없습니다.').min(1, '접두사는 최소 1자여야합니다.').max(32, '접두사는 최대 32자까지만 가능합니다.').required('접두사는 필수 항목입니다.'),
library: Yup.string().oneOf(library).required('라이브러리는 필수 항목입니다.'),
website: Yup.string().matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.').matches(Url, '올바른 웹사이트 URL을 입력해주세요.').max(64, 'URL은 최대 64자까지만 가능합니다.'),
url: Yup.string().matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.').matches(Url, '올바른 초대링크 URL을 입력해주세요.').max(64, 'URL은 최대 64자까지만 가능합니다.'),
git: Yup.string().matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.').matches(Url, '올바른 깃 URL을 입력해주세요.').max(64, 'URL은 최대 64자까지만 가능합니다.'),
discord: Yup.string().matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.').min(2, '지원 디스코드는 최소 2자여야합니다.').max(32, '지원 디스코드는 최대 32자까지만 가능합니다.'),
category: Yup.array(Yup.string().oneOf(categories)).min(1, '최소 한 개의 카테고리를 선택해주세요.').unique('카테고리는 중복될 수 없습니다.').required('카테고리는 필수 항목입니다.'),
intro: Yup.string().min(2, '봇 소개는 최소 2자여야합니다.').max(60, '봇 소개는 최대 60자여야합니다.').required('봇 소개는 필수 항목입니다.'),
desc: Yup.string().min(100, '봇 설명은 최소 100자여야합니다.').max(1500, '봇 설명은 최대 1500자여야합니다.').required('봇 설명은 필수 항목입니다.'),
_csrf: Yup.string().required()
agree: Yup.boolean()
.oneOf([true], '상단의 체크박스를 클릭해주세요.')
.required('상단의 체크박스를 클릭해주세요.'),
id: Yup.string()
.matches(ID, '올바른 봇 ID를 입력해주세요.')
.required('봇 ID는 필수 항목입니다.'),
prefix: Yup.string()
.matches(Prefix, '접두사는 띄어쓰기로 시작할 수 없습니다.')
.min(1, '접두사는 최소 1자여야합니다.')
.max(32, '접두사는 최대 32자까지만 가능합니다.')
.required('접두사는 필수 항목입니다.'),
library: Yup.string()
.oneOf(library)
.required('라이브러리는 필수 항목입니다.'),
website: Yup.string()
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
.matches(Url, '올바른 웹사이트 URL을 입력해주세요.')
.max(64, 'URL은 최대 64자까지만 가능합니다.'),
url: Yup.string()
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
.matches(Url, '올바른 초대링크 URL을 입력해주세요.')
.max(64, 'URL은 최대 64자까지만 가능합니다.'),
git: Yup.string()
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
.matches(Url, '올바른 깃 URL을 입력해주세요.')
.max(64, 'URL은 최대 64자까지만 가능합니다.'),
discord: Yup.string()
.matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.')
.min(2, '지원 디스코드는 최소 2자여야합니다.')
.max(32, '지원 디스코드는 최대 32자까지만 가능합니다.'),
category: Yup.array(Yup.string().oneOf(categories))
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
.unique('카테고리는 중복될 수 없습니다.')
.required('카테고리는 필수 항목입니다.'),
intro: Yup.string()
.min(2, '봇 소개는 최소 2자여야합니다.')
.max(60, '봇 소개는 최대 60자여야합니다.')
.required('봇 소개는 필수 항목입니다.'),
desc: Yup.string()
.min(100, '봇 설명은 최소 100자여야합니다.')
.max(1500, '봇 설명은 최대 1500자여야합니다.')
.required('봇 설명은 필수 항목입니다.'),
_csrf: Yup.string().required(),
})
export interface AddBotSubmit {
@ -121,32 +185,66 @@ export interface AddBotSubmit {
}
export const ManageBotSchema = Yup.object({
prefix: Yup.string().matches(Prefix, '접두사는 띄어쓰기로 시작할 수 없습니다.').min(1, '접두사는 최소 1자여야합니다.').max(32, '접두사는 최대 32자까지만 가능합니다.').required('접두사는 필수 항목입니다.'),
library: Yup.string().oneOf(library).required('라이브러리는 필수 항목입니다.'),
website: Yup.string().matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.').matches(Url, '올바른 웹사이트 URL을 입력해주세요.').max(64, 'URL은 최대 64자까지만 가능합니다.'),
url: Yup.string().matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.').matches(Url, '올바른 초대링크 URL을 입력해주세요.').max(64, 'URL은 최대 64자까지만 가능합니다.'),
git: Yup.string().matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.').matches(Url, '올바른 깃 URL을 입력해주세요.').max(64, 'URL은 최대 64자까지만 가능합니다.'),
discord: Yup.string().matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.').min(2, '지원 디스코드는 최소 2자여야합니다.').max(32, '지원 디스코드는 최대 32자까지만 가능합니다.'),
category: Yup.array(Yup.string().oneOf(categories)).min(1, '최소 한 개의 카테고리를 선택해주세요.').unique('카테고리는 중복될 수 없습니다.').required('카테고리는 필수 항목입니다.'),
intro: Yup.string().min(2, '봇 소개는 최소 2자여야합니다.').max(60, '봇 소개는 최대 60자여야합니다.').required('봇 소개는 필수 항목입니다.'),
desc: Yup.string().min(100, '봇 설명은 최소 100자여야합니다.').max(1500, '봇 설명은 최대 1500자여야합니다.').required('봇 설명은 필수 항목입니다.'),
owners: Yup.array(Yup.string()).min(1, '최소 한 명의 소유자는 입력해주세요.').max(10, '소유자는 최대 10명까지만 가능합니다.').unique('소유자 아이디는 중복될 수 없습니다.').required('소유자는 필수 항목입니다.'),
_csrf: Yup.string().required()
prefix: Yup.string()
.matches(Prefix, '접두사는 띄어쓰기로 시작할 수 없습니다.')
.min(1, '접두사는 최소 1자여야합니다.')
.max(32, '접두사는 최대 32자까지만 가능합니다.')
.required('접두사는 필수 항목입니다.'),
library: Yup.string()
.oneOf(library)
.required('라이브러리는 필수 항목입니다.'),
website: Yup.string()
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
.matches(Url, '올바른 웹사이트 URL을 입력해주세요.')
.max(64, 'URL은 최대 64자까지만 가능합니다.'),
url: Yup.string()
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
.matches(Url, '올바른 초대링크 URL을 입력해주세요.')
.max(64, 'URL은 최대 64자까지만 가능합니다.'),
git: Yup.string()
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
.matches(Url, '올바른 깃 URL을 입력해주세요.')
.max(64, 'URL은 최대 64자까지만 가능합니다.'),
discord: Yup.string()
.matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.')
.min(2, '지원 디스코드는 최소 2자여야합니다.')
.max(32, '지원 디스코드는 최대 32자까지만 가능합니다.'),
category: Yup.array(Yup.string().oneOf(categories))
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
.unique('카테고리는 중복될 수 없습니다.')
.required('카테고리는 필수 항목입니다.'),
intro: Yup.string()
.min(2, '봇 소개는 최소 2자여야합니다.')
.max(60, '봇 소개는 최대 60자여야합니다.')
.required('봇 소개는 필수 항목입니다.'),
desc: Yup.string()
.min(100, '봇 설명은 최소 100자여야합니다.')
.max(1500, '봇 설명은 최대 1500자여야합니다.')
.required('봇 설명은 필수 항목입니다.'),
owners: Yup.array(Yup.string())
.min(1, '최소 한 명의 소유자는 입력해주세요.')
.max(10, '소유자는 최대 10명까지만 가능합니다.')
.unique('소유자 아이디는 중복될 수 없습니다.')
.required('소유자는 필수 항목입니다.'),
_csrf: Yup.string().required(),
})
export const DeveloperBotSchema: Yup.SchemaOf<DeveloperBot> = Yup.object({
webhook: Yup.string().matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.').matches(Url, '올바른 웹훅 URL을 입력해주세요.').max(150, 'URL은 최대 150자까지만 가능합니다.'),
_csrf: Yup.string().required()
webhook: Yup.string()
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
.matches(Url, '올바른 웹훅 URL을 입력해주세요.')
.max(150, 'URL은 최대 150자까지만 가능합니다.'),
_csrf: Yup.string().required(),
})
export interface DeveloperBot {
webhook: string | null
webhook: string | null
_csrf: string
}
export const ResetBotTokenSchema = Yup.object({
token: Yup.string().required(),
_csrf: Yup.string().required()
_csrf: Yup.string().required(),
})
export interface ResetBotToken {

View File

@ -1,7 +1,7 @@
import { RefObject, useEffect } from 'react'
const useOutsideClick = (ref: RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (e) => {
const handleClick = e => {
if (ref.current && !ref.current.contains(e.target)) {
callback()
}
@ -16,4 +16,4 @@ const useOutsideClick = (ref: RefObject<HTMLElement>, callback: () => void) => {
})
}
export default useOutsideClick
export default useOutsideClick