mirror of
https://github.com/koreanbots/core.git
synced 2025-12-13 05:10:24 +00:00
Feature/serverlist skeleton (#468)
* deps: added mongoose * feat(*): added mongo and saving invited count * chore(env): updated mongo configuration * chore: updated next-env.d.ts * chore(*): changed categories to botCategories * chore(Image): maded image component * feat(ServerCard): added ServerCard component * feat(ServerIcon): added ServerIcon component * feat(Tools): added server related functions * feat(Mongo): added serverSchema * chore(Hero): support serverlist * feat(Owner): added crown * feat(icons): added icons api * feat(Yup): added AddServerSubmitSchema * types: added server related types * chore(BotCard): changed bot category link * chore(Hero): changed category links * feat(ServerCard): added unreachable state display * feat(Yup): added ManageServerSchema * feat(Query): added server related queries * feat(Constants): added server related stuffs * types: added updatedAt field for ServerData * feat(pages/servers/*): added server pages * feat(*): moved bot category rotue * typo: fixed typo issue * feat(pages/addserver/*): added add server page * feat(api/servers): added server related api * feat(pages/servers): added server edit page * feat(pages/bots): changed bot list route * feat(*): server categories * feat(pages/users): added owned server list * chore(pages/bots): changed image size * feat(docker-compose): added bot * ci: made some changes * types: fixed type * types(Search): fixed type * types(*): fixed type * fix(*): missing fields * fix: Hero type typo issue * ci(*): missing sentry org slug * ci(*): fix * feat(*): added and changed search pages * Update pages/addserver/[id].tsx Co-authored-by: Ryu JuHeon <saidbysolo@gmail.com> * feat(api/search): added servers search api * feat(pages/panel): added server list in manage page * feat(Search): supporting server search at SearchBox * feat(pages/apllications/servers): added server application page * chore(docker-compose): changed image link * chore(utils): removing server cache at submit * chore(image/icons): added debug code * chore(*): changed component names * chore(Query): decreased server cache ttl * fix(Query): error on addserver page close: https://github.com/koreanbots/serverlist-testing/issues/10 * fix(Query): not using vote type close: https://github.com/koreanbots/serverlist-testing/issues/9 * fix(Constants): fixed category unexpected char close: https://github.com/koreanbots/serverlist-testing/issues/8 * fix(Query): serialize server data * fix(Query): returning null on boost level 0 * fix(page/servers): displaying n/a on boostTier null close: https://github.com/koreanbots/serverlist-testing/issues/4 * fix(pages/servers): hiding emoji list if no emoji close: https://github.com/koreanbots/serverlist-testing/issues/1 * typo(pages/servers): bot to server close: https://github.com/koreanbots/serverlist-testing/issues/2 * fix(components/Hero): editing vote list link close: https://github.com/koreanbots/serverlist-testing/issues/11 * chore(*): changed list route * feat(pages/servers/list/votes): added server vote list page close: https://github.com/koreanbots/serverlist-testing/issues/12 * feat(Dockerfile): added pre-build * fix(Image): image broken when fallbackSrc not given close: https://github.com/koreanbots/serverlist-testing/issues/5 * ci: checking out submodules * fix(ServerCard): bot category displayed at ServerCard close: https://github.com/koreanbots/serverlist-testing/issues/16 * feat(*): supporting opengraph image for server * fix(utils/Constants): fixed type missing on og * feat(pages/servers): not forcing emoji width * chore(utils/Yup): fixed agree checkbox error message * typo(utils/Yup): fixed bot to server * feat(pages/servers): improved emoji display * chore(api/images/discord/icons): removed debug code * chore(pages/servers): removed crown for owner close: https://github.com/koreanbots/serverlist-testing/issues/19 * fix(utils/Query): returning date as string close: https://github.com/koreanbots/serverlist-testing/issues/23 * fix(ServerCard): changed manage link from bot manage link * fix(ServerCard): same height for every card * chore: removed debug code * chore(pages/addserver): showing as invite for server kicked bot * typo(*): fixed typo issues * types: added nullable type * feat(Navbar): added list menu * chore: showing warning for server data not fetched * chore: changed main page (combined bots and servers) * typo(*): replace '한국 디스코드봇 리스트' with '한국 디스코드 리스트' * chore: added Hero component combined state * typo: changed name * fix(Navbar): fix link href * typo: fix about page for serverlist * chore: decrease font size * fix: server category tag link * fix: bot category link * feat: added server widget * fix(ServerCard): fixed servername overflowing * chore: forcing re-login when discord server data fetch fails * fix: error causing on owner not registered * fix: making state same for join button * fix: filtering owner if null * fix(servers/[id]): fix error causing if owner is null * fix(addserver): fixed error occuring for users not logged in * fix(Constant): fixed og image extension getting popped * typo: fixed typo issue * fix: showing forbidden page for non-owner users * feat: invite guide for server which bot left * fix: invalid path for paginator on bot page Co-authored-by: Hajin Lim <zero734kr@gmail.com> Co-authored-by: Ryu JuHeon <saidbysolo@gmail.com>
This commit is contained in:
parent
1fbb685a38
commit
678fae4112
@ -4,6 +4,11 @@ MYSQL_USER=root
|
||||
MYSQL_PASSWORD=YOUSHALLNOTPASS
|
||||
MYSQL_DATABASE=discordbots
|
||||
|
||||
MONGO_HOST=mongo
|
||||
MONGO_USER=discordbots
|
||||
MONGO_PASSWORD=YOUSHALLNOTPASS
|
||||
MONGO_DATABASE=discordbots
|
||||
|
||||
DISCORD_CLIENT_ID=CLIENT_ID
|
||||
DISCORD_CLIENT_SECRET=CLIENT_SECRET
|
||||
DISCORD_SCOPE=SCOPE
|
||||
|
||||
43
.github/workflows/publish.yml
vendored
Normal file
43
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
name: Publish
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request: # debug
|
||||
branches: '*'
|
||||
tags: '*'
|
||||
|
||||
jobs:
|
||||
image-push:
|
||||
name: Push docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
- uses: docker/setup-buildx-action@v1
|
||||
- name: Parse image tag
|
||||
run: |
|
||||
parsed=${GITHUB_REF#refs/*/}
|
||||
echo "RELEASE_TAG=${parsed//\//-}" >> $GITHUB_ENV
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v1
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ap-northeast-2
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v1
|
||||
- name: Build and push
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
run: |
|
||||
printf 'defaults.url=https://sentry.io/\ndefaults.org=koreanbots\ndefaults.project=client' > sentry.properties
|
||||
docker build --build-arg SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN --build-arg NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN --build-arg SENTRY_DSN=$SENTRY_DSN --build-arg SOURCE_COMMIT=${{ env.GITHUB_SHA }} --build-arg TAG=${{ env.RELEASE_TAG }} -t koreanlist .
|
||||
docker tag koreanlist:latest ${{ secrets.AWS_IMAGE_URL }}:latest
|
||||
docker tag koreanlist:latest ${{ secrets.AWS_IMAGE_URL }}:${{ env.RELEASE_TAG == 'master' && 'nightly' || env.RELEASE_TAG }}
|
||||
docker push ${{ secrets.AWS_IMAGE_URL }} --all-tags
|
||||
61
.github/workflows/testing.yml
vendored
61
.github/workflows/testing.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
env:
|
||||
CI: true
|
||||
test:
|
||||
name: Run Test
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@ -34,56 +34,25 @@ jobs:
|
||||
node-version: 14
|
||||
- name: yarn install
|
||||
run: yarn install
|
||||
- name: Setup MySQL
|
||||
uses: getong/mariadb-action@v1.1
|
||||
with:
|
||||
mysql database: 'discordbots'
|
||||
mysql root password: 'test'
|
||||
- name: Run Jest
|
||||
run: yarn test
|
||||
- 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
|
||||
printf 'defaults.url=https://sentry.io/\ndefaults.org=koreanbots\ndefaults.project=client' > sentry.properties
|
||||
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: Build
|
||||
run: yarn build
|
||||
run: |
|
||||
printf 'defaults.url=https://sentry.io/\ndefaults.org=koreanbots\ndefaults.project=client' > sentry.properties
|
||||
yarn build
|
||||
env:
|
||||
CI: true
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
|
||||
# docker:
|
||||
# needs:
|
||||
# - eslint
|
||||
# - build
|
||||
# - test
|
||||
# name: Docker Image CI
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
# - name: install node v14
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: 14
|
||||
# - 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: Docker Compose
|
||||
# run: docker-compose up -d
|
||||
|
||||
@ -2,11 +2,16 @@ import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
|
||||
const Application: React.FC<ApplicationProps> = ({ type, id, name }) => {
|
||||
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' />
|
||||
{
|
||||
type === 'bot' ?
|
||||
<DiscordAvatar userID={id} className='px-2 w-full rounded-xl' /> :
|
||||
<ServerIcon id={id} className='px-2 w-full rounded-xl' />
|
||||
}
|
||||
<h2 className='pt-2 whitespace-nowrap text-xl font-medium truncate'>{name}</h2>
|
||||
</div>
|
||||
</Link>
|
||||
@ -14,7 +19,7 @@ const Application: React.FC<ApplicationProps> = ({ type, id, name }) => {
|
||||
}
|
||||
|
||||
interface ApplicationProps {
|
||||
type: 'bot'
|
||||
type: 'bot' | 'server'
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
|
||||
<div>
|
||||
<div className='category flex flex-wrap px-2'>
|
||||
{bot.category.slice(0, 3).map(el => (
|
||||
<Tag key={el} text={el} href={`/categories/${el}`} dark />
|
||||
<Tag key={el} text={el} href={`/bots/categories/${el}`} dark />
|
||||
))}{' '}
|
||||
{bot.category.length > 3 && <Tag text={`+${bot.category.length - 3}`} dark />}
|
||||
</div>
|
||||
@ -96,8 +96,7 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
|
||||
</a> :
|
||||
<a
|
||||
href={
|
||||
bot.url ||
|
||||
`https://discordapp.com/oauth2/authorize?client_id=${bot.id}&scope=bot&permissions=0`
|
||||
makeBotURL(bot) + '/invite'
|
||||
}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
|
||||
@ -13,9 +13,9 @@ const DeveloperLayout: React.FC<DeveloperLayout> = ({ children, enabled, docs, c
|
||||
const [ navbarEnabled, setNavbarOpen ] = useState(false)
|
||||
|
||||
return <div className='flex min-h-screen'>
|
||||
<NextSeo title='한디리 개발자' description='한국 디스코드봇 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' openGraph={{
|
||||
<NextSeo title='한디리 개발자' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' openGraph={{
|
||||
title:'한디리 개발자',
|
||||
description:'한국 디스코드봇 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.'
|
||||
description:'한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.'
|
||||
}} />
|
||||
<div className='block lg:hidden h-screen relative'>
|
||||
<div className='w-18 pt-20 px-2 h-full text-center bg-little-white dark:bg-discord-black fixed'>
|
||||
@ -43,7 +43,7 @@ const DeveloperLayout: React.FC<DeveloperLayout> = ({ children, enabled, docs, c
|
||||
<Divider className='lg:hidden' />
|
||||
<Link href='/developers/applications'>
|
||||
<li className={`cursor-pointer py-2 px-4 rounded-md ${enabled === 'applications' ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}>
|
||||
나의 봇
|
||||
나의 리스트
|
||||
</li>
|
||||
</Link>
|
||||
<Link href='/developers/docs'>
|
||||
|
||||
@ -1,44 +1,16 @@
|
||||
import { SyntheticEvent, useEffect, useState } from 'react'
|
||||
import { SyntheticEvent } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { supportsWebP } from '@utils/Tools'
|
||||
import Logger from '@utils/Logger'
|
||||
|
||||
const Image = dynamic(() => import('@components/Image'))
|
||||
|
||||
const DiscordAvatar: React.FC<DiscordAvatarProps> = props => {
|
||||
const fallback = '/img/default.png'
|
||||
const [ webpUnavailable, setWebpUnavailable ] = useState<boolean>()
|
||||
|
||||
useEffect(()=> {
|
||||
setWebpUnavailable(localStorage.webp === 'false')
|
||||
}, [])
|
||||
|
||||
return <img
|
||||
alt={props.alt ?? 'Image'}
|
||||
loading='lazy'
|
||||
className={props.className}
|
||||
src={
|
||||
KoreanbotsEndPoints.CDN.avatar(props.userID, { format: !webpUnavailable ? 'webp' : 'png', size: props.size ?? 256})
|
||||
}
|
||||
onError={(e: SyntheticEvent<HTMLImageElement, ImageEvent>)=> {
|
||||
if(webpUnavailable) {
|
||||
(e.target as ImageTarget).onerror = (event) => {
|
||||
// All Fails
|
||||
(event.target as ImageTarget).onerror = ()=> { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
|
||||
(event.target as ImageTarget).src = fallback
|
||||
|
||||
}
|
||||
}
|
||||
else {
|
||||
(e.target as ImageTarget).onerror = (event) => {
|
||||
// All Fails
|
||||
(event.target as ImageTarget).onerror = ()=> { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
|
||||
(event.target as ImageTarget).src = fallback
|
||||
}
|
||||
// Webp Load Fail
|
||||
(e.target as ImageTarget).src = KoreanbotsEndPoints.CDN.avatar(props.userID, { size: props.size ?? 256})
|
||||
if(!supportsWebP()) localStorage.setItem('webp', 'false')
|
||||
}
|
||||
}}
|
||||
return <Image
|
||||
{...props}
|
||||
src={KoreanbotsEndPoints.CDN.avatar(props.userID, { format: 'webp', size: props.size ?? 256})}
|
||||
fallbackSrc={KoreanbotsEndPoints.CDN.avatar(props.userID, { format: 'png', size: props.size ?? 256})}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
interface DiscordAvatarProps {
|
||||
|
||||
@ -12,8 +12,8 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
|
||||
<div className='bottom-0 text-white bg-discord-black py-24'>
|
||||
<Container className='w-11/12 lg:flex lg:pt-0 lg:w-4/5' ignoreColor>
|
||||
<div className='w-full lg:w-2/5'>
|
||||
<h1 className='text-koreanbots-blue text-3xl font-bold'>국내 디스코드 봇을 한 곳에서.</h1>
|
||||
<span className='text-base'>2020-2021 Koreanbots, All rights reserved.</span>
|
||||
<h1 className='text-koreanbots-blue text-2xl font-bold'>국내 디스코드의 모든 것을 한 곳에서.</h1>
|
||||
<span className='text-base'>2020-2021 한국 디스코드 리스트, All rights reserved.</span>
|
||||
<div className='text-2xl'>
|
||||
<Link href='/discord'>
|
||||
<a className='mr-2'>
|
||||
@ -30,7 +30,7 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
|
||||
</div>
|
||||
<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>
|
||||
<h2 className='text-koreanbots-blue text-base font-bold'>한국 디스코드 리스트</h2>
|
||||
<ul className='text-sm'>
|
||||
<li>
|
||||
<Link href='/about'>
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
import { categories, categoryIcon } from '@utils/Constants'
|
||||
import { botCategories, botCategoryIcon, serverCategories, serverCategoryIcon } from '@utils/Constants'
|
||||
|
||||
const Container = dynamic(()=> import('@components/Container'))
|
||||
const Tag = dynamic(()=> import('@components/Tag'))
|
||||
const Search = dynamic(()=> import('@components/Search'))
|
||||
|
||||
const Hero:React.FC<HeroProps> = ({ header, description }) => {
|
||||
const Hero:React.FC<HeroProps> = ({ type='all', header, description }) => {
|
||||
const link = `/${type}/categories`
|
||||
return <>
|
||||
<NextSeo title={header} description={description} openGraph={{
|
||||
title: header,
|
||||
@ -16,23 +17,45 @@ const Hero:React.FC<HeroProps> = ({ header, description }) => {
|
||||
<div className='dark:bg-discord-black bg-discord-blurple text-gray-100 md:p-0 mb-8'>
|
||||
<Container className='pt-24 pb-16 md:pb-20' ignoreColor>
|
||||
<h1 className='hidden md:block text-left text-3xl font-bold'>
|
||||
{ header && `${header} - `}한국 디스코드봇 리스트
|
||||
{ header && `${header} - `}한국 디스코드 리스트
|
||||
</h1>
|
||||
<h1 className='md:hidden text-center text-3xl font-semibold'>
|
||||
{ header && <span className='text-4xl'>{header}<br/></span>}한국 디스코드봇 리스트
|
||||
{ header && <span className='text-4xl'>{header}<br/></span>}한국 디스코드 리스트
|
||||
</h1>
|
||||
<p className='text-center sm:text-left text-xl font-base mt-2'>{description || '다양한 국내 디스코드봇을 한곳에서 확인하세요!'}</p>
|
||||
<p className='text-center sm:text-left text-xl font-base mt-2'>{description || `${type !== 'all' ? '다양한 ' : ''}국내 디스코드${{ all: '의 모든 것을', bots: ' 봇들을', servers: ' 서버들을' }[type]} 한 곳에서 확인하세요!`}</p>
|
||||
<Search />
|
||||
<div className='flex flex-wrap mt-5'>
|
||||
<Tag key='list' text={<>
|
||||
<i className='fas fa-heart text-red-600'/> 하트 랭킹
|
||||
</>} dark bigger href='/list/votes' />
|
||||
{ categories.slice(0, 4).map(t=> <Tag key={t} text={<>
|
||||
<i className={categoryIcon[t]} /> {t}
|
||||
</>} dark bigger href={`/categories/${t}`} />) }
|
||||
<Tag key='tag' text={<>
|
||||
<i className='fas fa-tag'/> 카테고리 더보기
|
||||
</>} dark bigger href='/categories' />
|
||||
{
|
||||
type === 'all' ? <>
|
||||
<Tag text={
|
||||
<>
|
||||
<i className='fas fa-robot text-koreanbots-blue'/> 봇 리스트
|
||||
</>
|
||||
} dark bigger href='/bots' />
|
||||
<Tag text={
|
||||
<>
|
||||
<i className='fas fa-users text-koreanbots-blue'/> 서버 리스트
|
||||
</>
|
||||
} dark bigger href='/servers' />
|
||||
{
|
||||
botCategories.slice(0, 2).map(t => <Tag key={t} text={<><i className={botCategoryIcon[t]} /> {t} 봇</>} dark bigger href={`/bots/categories/${t}`} />)
|
||||
}
|
||||
|
||||
{
|
||||
serverCategories.slice(0, 2).map(t => <Tag key={t} text={<><i className={serverCategoryIcon[t]} /> {t} 서버</>} dark bigger href={`/servers/categories/${t}`} />)
|
||||
}
|
||||
</>: <>
|
||||
<Tag key='list' text={<>
|
||||
<i className='fas fa-heart text-red-600'/> 하트 랭킹
|
||||
</>} dark bigger href={type === 'bots' ? '/bots/list/votes' : '/servers/list/votes'} />
|
||||
{ (type === 'bots' ? botCategories : serverCategories).slice(0, 4).map(t=> <Tag key={t} text={<>
|
||||
<i className={(type === 'bots' ? botCategoryIcon : serverCategoryIcon)[t]} /> {t}
|
||||
</>} dark bigger href={`${link}/${t}`} />) }
|
||||
<Tag key='tag' text={<>
|
||||
<i className='fas fa-tag'/> 카테고리 더보기
|
||||
</>} dark bigger href={link} />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
@ -40,6 +63,7 @@ const Hero:React.FC<HeroProps> = ({ header, description }) => {
|
||||
}
|
||||
|
||||
interface HeroProps {
|
||||
type?: 'all' | 'bots' | 'servers'
|
||||
header?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
62
components/Image.tsx
Normal file
62
components/Image.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { SyntheticEvent, useEffect, useState } from 'react'
|
||||
import { supportsWebP } from '@utils/Tools'
|
||||
import Logger from '@utils/Logger'
|
||||
|
||||
const BaseImage: React.FC<ImageProps> = props => {
|
||||
const fallback = '/img/default.png'
|
||||
const [ webpUnavailable, setWebpUnavailable ] = useState<boolean>()
|
||||
|
||||
useEffect(()=> {
|
||||
setWebpUnavailable(localStorage.webp === 'false')
|
||||
}, [])
|
||||
|
||||
return <img
|
||||
alt={props.alt ?? 'Image'}
|
||||
loading='lazy'
|
||||
className={props.className}
|
||||
src={
|
||||
webpUnavailable && props.fallbackSrc || props.src
|
||||
}
|
||||
onError={(e: SyntheticEvent<HTMLImageElement, ImageEvent>)=> {
|
||||
if(webpUnavailable) {
|
||||
(e.target as ImageTarget).onerror = (event) => {
|
||||
// All Fails
|
||||
(event.target as ImageTarget).onerror = () => { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
|
||||
(event.target as ImageTarget).src = fallback
|
||||
|
||||
}
|
||||
}
|
||||
else if (props.fallbackSrc) {
|
||||
(e.target as ImageTarget).onerror = (event) => {
|
||||
// All Fails
|
||||
(event.target as ImageTarget).onerror = () => { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
|
||||
(event.target as ImageTarget).src = fallback
|
||||
}
|
||||
// Webp Load Fail
|
||||
(e.target as ImageTarget).src = props.fallbackSrc
|
||||
if(!supportsWebP()) localStorage.setItem('webp', 'false')
|
||||
}
|
||||
else {
|
||||
(e.target as ImageTarget).src = fallback
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
interface ImageProps {
|
||||
alt?: string
|
||||
className?: string
|
||||
src: string
|
||||
fallbackSrc?: string
|
||||
}
|
||||
|
||||
interface ImageEvent extends Event {
|
||||
target: ImageTarget
|
||||
}
|
||||
|
||||
interface ImageTarget extends EventTarget {
|
||||
src: string
|
||||
onerror: (event: SyntheticEvent<HTMLImageElement, ImageEvent>) => void
|
||||
}
|
||||
|
||||
export default BaseImage
|
||||
@ -8,7 +8,7 @@ import { useRouter } from 'next/router'
|
||||
|
||||
import { redirectTo } from '@utils/Tools'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { User, UserCache } from '@types'
|
||||
import { Nullable, User, UserCache } from '@types'
|
||||
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
|
||||
@ -18,6 +18,7 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false)
|
||||
const router = useRouter()
|
||||
const logged = userCache?.id && userCache.version === 2
|
||||
const type: Nullable<'bot'|'server'> = router.pathname.startsWith('/bots') ? 'bot' : router.pathname.startsWith('/servers') ? 'server' : null
|
||||
const dev = router.pathname.startsWith('/developers')
|
||||
|
||||
useEffect(() => {
|
||||
@ -64,6 +65,24 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
{
|
||||
type !== 'bot' && <li className='flex items-center'>
|
||||
<Link href='/bots'>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
봇 리스트
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
}
|
||||
{
|
||||
type !== 'server' && <li className='flex items-center'>
|
||||
<Link href='/servers'>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
서버 리스트
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
}
|
||||
<li className='flex items-center'>
|
||||
<Link href='/discord'>
|
||||
<a target='_blank' rel='noreferrer' className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'
|
||||
@ -79,13 +98,24 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className='flex items-center'>
|
||||
<Link href='/addbot'>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
{
|
||||
type === 'bot' && <li className='flex items-center'>
|
||||
<Link href='/addbot'>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
봇 추가하기
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
}
|
||||
{
|
||||
type === 'server' && <li className='flex items-center'>
|
||||
<Link href='/addserver'>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
서버 추가하기
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div className='hidden flex-grow items-center bg-white lg:flex lg:bg-transparent lg:shadow-none'>
|
||||
@ -154,6 +184,22 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
{
|
||||
type !== 'bot' && <Link href='/bots'>
|
||||
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fas fa-robot' />
|
||||
<span className='px-2 font-medium'>봇 리스트</span>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
{
|
||||
type !== 'server' && <Link href='/servers'>
|
||||
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fas fa-users' />
|
||||
<span className='px-2 font-medium'>서버 리스트</span>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
<Link href='/discord'>
|
||||
<a target='_blank' rel='noreferrer' onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fab fa-discord' />
|
||||
@ -166,12 +212,22 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
|
||||
<span className='px-2 font-medium'>소개</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href='/addbot'>
|
||||
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fas fa-plus' />
|
||||
<span className='px-2 font-medium'>봇 추가하기</span>
|
||||
</a>
|
||||
</Link>
|
||||
{
|
||||
type === 'bot' && <Link href='/addbot'>
|
||||
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fas fa-plus' />
|
||||
<span className='px-2 font-medium'>봇 추가하기</span>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
{
|
||||
type === 'server' && <Link href='/addserver'>
|
||||
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fas fa-plus' />
|
||||
<span className='px-2 font-medium'>서버 추가하기</span>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div className='my-10'>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import DiscordAvatar from '@components/DiscordAvatar'
|
||||
|
||||
const Owner: React.FC<OwnerProps> = ({ id, username, tag }) => {
|
||||
const Owner: React.FC<OwnerProps> = ({ id, username, tag, crown=false }) => {
|
||||
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'>
|
||||
@ -9,8 +9,9 @@ const Owner: React.FC<OwnerProps> = ({ id, username, tag }) => {
|
||||
<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>
|
||||
<h4 className='whitespace-nowrap'>{ crown && <i className='fas fa-crown text-yellow-300 text-xs' /> }{username}</h4>
|
||||
<span className='text-gray-600 text-sm'>#{tag}</span>
|
||||
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
@ -23,4 +24,5 @@ interface OwnerProps {
|
||||
id: string
|
||||
tag: string
|
||||
username: string
|
||||
crown?: boolean
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import Head from 'next/head'
|
||||
const SEO: React.FC<SEOProps> = ({ title, description, image }: SEOProps) => {
|
||||
return (
|
||||
<Head>
|
||||
<title>{title} - 한국 디스코드봇 리스트</title>
|
||||
<title>{title} - 한국 디스코드 리스트</title>
|
||||
{description && <meta name='description' content={description} />}
|
||||
<meta name='og:title' content={title} />
|
||||
{description && <meta name='og:description' content={description} />}
|
||||
|
||||
@ -1,22 +1,25 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import AbortController from 'abort-controller'
|
||||
|
||||
import { makeBotURL, redirectTo } from '@utils/Tools'
|
||||
import { makeBotURL, makeServerURL, redirectTo } from '@utils/Tools'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { BotList, ResponseProps } from '@types'
|
||||
import { Bot, Server, ResponseProps } from '@types'
|
||||
|
||||
import DiscordAvatar from '@components/DiscordAvatar'
|
||||
import Day from '@utils/Day'
|
||||
import useOutsideClick from '@utils/useOutsideClick'
|
||||
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
|
||||
const Search: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const ref = useRef()
|
||||
const [query, setQuery] = useState('')
|
||||
const [recentSearch, setRecentSearch] = useState([])
|
||||
const [data, setData] = useState<ResponseProps<BotList>>(null)
|
||||
const [data, setData] = useState<ResponseProps<ListAll>>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortControl, setAbortControl] = useState(new AbortController())
|
||||
const [hidden, setHidden] = useState(true)
|
||||
@ -32,6 +35,7 @@ const Search: React.FC = () => {
|
||||
}, [router])
|
||||
useOutsideClick(ref, () => setHidden(true))
|
||||
const SearchResults = async (value: string) => {
|
||||
setData(null)
|
||||
setQuery(value)
|
||||
try {
|
||||
abortControl.abort()
|
||||
@ -41,7 +45,7 @@ const Search: React.FC = () => {
|
||||
const controller = new AbortController()
|
||||
setAbortControl(controller)
|
||||
if (value.length > 1) setLoading(true)
|
||||
const res = await Fetch<BotList>(`/search/bots?q=${encodeURIComponent(value)}`, {
|
||||
const res = await Fetch<ListAll>(`/search/all?q=${encodeURIComponent(value)}`, {
|
||||
signal: controller.signal,
|
||||
}).catch((e) => {
|
||||
if(e.name !== 'AbortError') throw e
|
||||
@ -105,72 +109,94 @@ const Search: React.FC = () => {
|
||||
<div className={`relative ${hidden ? 'hidden' : 'block'} z-50`}>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{query && data ? (
|
||||
data.message?.includes('문법') ? (
|
||||
<li className='px-3 py-3.5'>
|
||||
검색 문법이 잘못되었습니다.
|
||||
<br />
|
||||
<a
|
||||
className='hover:text-blue-400 text-blue-500'
|
||||
href='https://docs.koreanbots.dev/bots/usage/search'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
더 알아보기
|
||||
</a>
|
||||
</li>
|
||||
) : <li className='px-3 py-3.5'>{(data.errors && data.errors[0]) || data.message}</li>
|
||||
) : query.length === 0 ? !recentSearch || !Array.isArray(recentSearch) || recentSearch.length === 0? <li className='px-3 py-3.5'>최근 검색 기록이 없습니다.</li>
|
||||
: <>
|
||||
<li className='h-15 px-3 py-2 cursor-pointer font-semibold'>
|
||||
최근 검색어
|
||||
<button className='absolute right-0 pr-10 text-sm text-red-500 hover:opacity-90' onClick={() => {
|
||||
setRecentSearch([])
|
||||
localStorage.recentSearch = '[]'
|
||||
}}>
|
||||
전체 삭제
|
||||
</button>
|
||||
</li>
|
||||
{
|
||||
recentSearch.slice(0, 10).map((el, n) => (
|
||||
<Link key={n} href={`/search?q=${encodeURIComponent(el?.value)}`}>
|
||||
<li className='h-15 px-3 py-2 cursor-pointer'>
|
||||
<i className='fas fa-history' /> {el?.value}
|
||||
<span className='absolute right-0 pr-10 text-gray-400 text-sm'>
|
||||
{Day(el?.date).format('MM.DD.')}
|
||||
</span>
|
||||
{(data && data.code === 200) ? (
|
||||
<div className='grid lg:grid-cols-2'>
|
||||
<ul>
|
||||
<li className='px-3 py-3.5 font-bold'>봇</li>
|
||||
{
|
||||
data.data.bots.length === 0 ?
|
||||
<li className='px-3 py-3.5'>검색 결과가 없습니다.</li> :
|
||||
data.data.bots.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>
|
||||
))
|
||||
}
|
||||
</> :
|
||||
query.length < 3 ? (
|
||||
'최소 2글자 이상 입력해주세요.'
|
||||
) : (
|
||||
'검색어를 입력해주세요.'
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
</ul>
|
||||
<ul>
|
||||
<li className='px-3 py-3.5 font-bold'>서버</li>
|
||||
{
|
||||
data.data.servers.length === 0 ?
|
||||
<li className='px-3 py-3.5'>검색 결과가 없습니다.</li> :
|
||||
data.data.servers.map(el => (
|
||||
<Link key={el.id} href={makeServerURL(el)}>
|
||||
<li className='h-15 flex px-3 py-2 cursor-pointer'>
|
||||
<ServerIcon className='mt-1 w-12 h-12' size={128} id={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>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
) : loading ? <ul>
|
||||
<li className='px-3 py-3.5'>검색중입니다...</li>
|
||||
</ul> : <ul>
|
||||
{query && data ? (
|
||||
data.message?.includes('문법') ? (
|
||||
<li className='px-3 py-3.5'>
|
||||
검색 문법이 잘못되었습니다.
|
||||
<br />
|
||||
<a
|
||||
className='hover:text-blue-400 text-blue-500'
|
||||
href='https://docs.koreanbots.dev/bots/usage/search'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
더 알아보기
|
||||
</a>
|
||||
</li>
|
||||
) : <li className='px-3 py-3.5'>{(data.errors && data.errors[0]) || data.message || '검색중입니다...'}</li>
|
||||
) : query.length === 0 ? !recentSearch || !Array.isArray(recentSearch) || recentSearch.length === 0? <li className='px-3 py-3.5'>최근 검색 기록이 없습니다.</li>
|
||||
: <>
|
||||
<li className='h-15 px-3 py-2 cursor-pointer font-semibold'>
|
||||
최근 검색어
|
||||
<button className='absolute right-0 pr-10 text-sm text-red-500 hover:opacity-90' onClick={() => {
|
||||
setRecentSearch([])
|
||||
localStorage.recentSearch = '[]'
|
||||
}}>
|
||||
전체 삭제
|
||||
</button>
|
||||
</li>
|
||||
{
|
||||
recentSearch.slice(0, 10).map((el, n) => (
|
||||
<Link key={n} href={`/search?q=${encodeURIComponent(el?.value)}`}>
|
||||
<li className='h-15 px-3 py-2 cursor-pointer'>
|
||||
<i className='fas fa-history' /> {el?.value}
|
||||
<span className='absolute right-0 pr-10 text-gray-400 text-sm'>
|
||||
{Day(el?.date).format('MM.DD.')}
|
||||
</span>
|
||||
</li>
|
||||
</Link>
|
||||
))
|
||||
}
|
||||
</> :
|
||||
query.length < 3 ? (
|
||||
'최소 2글자 이상 입력해주세요.'
|
||||
) : (
|
||||
'검색어를 입력해주세요.'
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -179,3 +205,8 @@ const Search: React.FC = () => {
|
||||
}
|
||||
|
||||
export default Search
|
||||
|
||||
interface ListAll {
|
||||
bots: Bot[],
|
||||
servers: Server[]
|
||||
}
|
||||
164
components/ServerCard.tsx
Normal file
164
components/ServerCard.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { checkServerFlag, formatNumber, makeServerURL } from '@utils/Tools'
|
||||
import { ServerData, ServerState } from '@types'
|
||||
import { DiscordEnpoints, DSKR_BOT_ID } from '@utils/Constants'
|
||||
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
const Tag = dynamic(() => import('@components/Tag'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
|
||||
const ServerCard: React.FC<BotCardProps> = ({ type, server }) => {
|
||||
const newServerLink = server.data ? `/addserver/${server.id}` : `${DiscordEnpoints.InviteApplication(DSKR_BOT_ID, {}, 'bot', null, server.id)}&disable_guild_select=true`
|
||||
return <div className='container mb-16 transform hover:-translate-y-1 transition duration-100 ease-in cursor-pointer'>
|
||||
<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={
|
||||
checkServerFlag(server.flags, 'trusted') && server.banner
|
||||
? {
|
||||
background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${server.banner}") center top / cover no-repeat`,
|
||||
color: 'white',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Link href={type !== 'add' ? makeServerURL(server) : newServerLink}>
|
||||
<div>
|
||||
<div className='flex h-44'>
|
||||
<div className='w-3/5'>
|
||||
<div className='flex justify-start'>
|
||||
<ServerIcon
|
||||
size={128}
|
||||
id={server.id}
|
||||
hash={type === 'add' && server.icon}
|
||||
alt='Icon'
|
||||
className='absolute -left-2 -top-8 mx-auto w-32 h-32 bg-white rounded-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-28 px-4'>
|
||||
<h2 className={`px-1 text-sm ${server.state !== 'unreachable' ? ' invisible' : ''}`}>
|
||||
<i className='fas fa-ban text-red-600' />정보 갱신 불가
|
||||
</h2>
|
||||
<h1 className='mb-3 text-left text-2xl font-bold truncate'>{server.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 pr-5 py-5 w-2/5 h-0'>
|
||||
<Tag
|
||||
text={
|
||||
<>
|
||||
<i className='fas fa-heart text-red-600' /> {formatNumber(server.votes)}
|
||||
</>
|
||||
}
|
||||
dark
|
||||
/>
|
||||
<Tag
|
||||
blurple
|
||||
text={server.members ? <>{formatNumber(server.members)} 멤버</> : 'N/A'}
|
||||
dark
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='mb-10 px-4 h-6 text-left text-gray-400 text-sm font-medium'>
|
||||
{type === 'add' ?
|
||||
server.data ? '지금 바로 서버를 등록할 수 있습니다.' : '봇을 초대해야 서버를 등록할 수 있습니다.'
|
||||
: server.intro
|
||||
}
|
||||
</p>
|
||||
<div>
|
||||
<div className='category flex flex-wrap px-2'>
|
||||
{server.category?.slice(0, 3).map(el => (
|
||||
<Tag key={el} text={el} href={`/servers/categories/${el}`} dark />
|
||||
))}{' '}
|
||||
{server.category?.length > 3 && <Tag text={`+${server.category.length - 3}`} dark />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Divider />
|
||||
<div className='w-full'>
|
||||
<div className='flex justify-evenly'>
|
||||
{
|
||||
type === 'add' ?
|
||||
server.data ? <Link href={newServerLink}>
|
||||
<a className='py-3 w-full text-center text-green-500 hover:text-white text-sm font-bold hover:bg-green-500 rounded-b-2xl hover:shadow-lg transition duration-100 ease-in'>
|
||||
등록하기
|
||||
</a>
|
||||
</Link> : <Link href={newServerLink}>
|
||||
<a
|
||||
className='py-3 w-full text-center text-discord-blurple hover:text-white text-sm font-bold hover:bg-discord-blurple rounded-b-2xl hover:shadow-lg transition duration-100 ease-in'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
봇 초대하기
|
||||
</a>
|
||||
</Link>
|
||||
:
|
||||
<>
|
||||
<Link href={makeServerURL(server)}>
|
||||
<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>
|
||||
{type === 'manage' ? (
|
||||
<Link href={`/servers/${server.id}/edit`}>
|
||||
<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>
|
||||
) : !['ok', 'unreachable'].includes(server.state) ? <a
|
||||
className='py-3 w-full text-center text-discord-blurple text-sm font-bold rounded-br-2xl hover:shadow-lg transition duration-100 ease-in opacity-50 cursor-default select-none'
|
||||
>
|
||||
참가하기
|
||||
</a> :
|
||||
<a
|
||||
href={
|
||||
makeServerURL(server) + '/join'
|
||||
}
|
||||
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>
|
||||
}
|
||||
</>
|
||||
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
interface BotCardProps {
|
||||
type: 'list' | 'manage' | 'add'
|
||||
server: {
|
||||
id: string,
|
||||
name: string,
|
||||
intro?: string
|
||||
desc?: string,
|
||||
flags?: number
|
||||
state?: ServerState
|
||||
icon: string | null,
|
||||
banner?: string | null,
|
||||
bg?: string | null,
|
||||
vanity?: string | null
|
||||
category?: string[]
|
||||
votes?: number | null
|
||||
members?: number | null,
|
||||
data?: ServerData
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerCard
|
||||
35
components/ServerIcon.tsx
Normal file
35
components/ServerIcon.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { SyntheticEvent } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { DiscordEnpoints, KoreanbotsEndPoints } from '@utils/Constants'
|
||||
|
||||
const Image = dynamic(() => import('@components/Image'))
|
||||
|
||||
const ServerIcon: React.FC<ServerIconProps> = ({ id, size, className, alt, hash }) => {
|
||||
return <Image
|
||||
className={className}
|
||||
alt={alt}
|
||||
src={hash ? DiscordEnpoints.CDN.guild(id, hash, { format: 'webp', size: size ?? 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'webp', size: size ?? 256})}
|
||||
fallbackSrc={hash ? DiscordEnpoints.CDN.guild(id, hash, { format: 'png', size: size ?? 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'png', size: size ?? 256})}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
interface ServerIconProps {
|
||||
alt?: string
|
||||
id: string
|
||||
hash?: string
|
||||
fromDiscord?: boolean
|
||||
className?: string
|
||||
size? : 128 | 256 | 512
|
||||
}
|
||||
|
||||
interface ImageEvent extends Event {
|
||||
target: ImageTarget
|
||||
}
|
||||
|
||||
interface ImageTarget extends EventTarget {
|
||||
src: string
|
||||
onerror: (event: SyntheticEvent<HTMLImageElement, ImageEvent>) => void
|
||||
}
|
||||
|
||||
export default ServerIcon
|
||||
@ -2,9 +2,10 @@ version: '3'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
container_name: mysql-stable
|
||||
restart: always
|
||||
image: wonderlandpark/mariadb-mroonga:latest
|
||||
hostname: mysql
|
||||
container_name: mysql-stable
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
@ -12,12 +13,26 @@ services:
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- /home/ubuntu/mysql:/var/lib/mysql
|
||||
mongo:
|
||||
container_name: mongo-stable
|
||||
restart: always
|
||||
image: mongo:5.0
|
||||
hostname: mongo
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /home/ubuntu/mongo:/data/db
|
||||
web:
|
||||
container_name: web-stable
|
||||
restart: always
|
||||
image: wonderlandpark/koreanbots:nightly
|
||||
ports:
|
||||
- 5000:3000
|
||||
- 3000:3000
|
||||
links:
|
||||
- mysql
|
||||
env_file:
|
||||
- .env.production.local
|
||||
image: wonderlandpark/koreanbots:stable
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500M
|
||||
|
||||
@ -2,9 +2,10 @@ version: '3'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
container_name: mysql
|
||||
restart: always
|
||||
image: wonderlandpark/mariadb-mroonga:latest
|
||||
hostname: mysql
|
||||
container_name: mysql
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
@ -12,15 +13,34 @@ services:
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- /home/ubuntu/mysql-beta:/var/lib/mysql
|
||||
mongo:
|
||||
container_name: mongo
|
||||
restart: always
|
||||
image: mongo:5.0
|
||||
hostname: mongo
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /home/ubuntu/mongo-beta:/data/db
|
||||
bot:
|
||||
container_name: bot
|
||||
restart: always
|
||||
image: 397554924689.dkr.ecr.ap-northeast-2.amazonaws.com/dskr
|
||||
links:
|
||||
- mongo
|
||||
env_file:
|
||||
- .env
|
||||
web:
|
||||
container_name: web
|
||||
restart: always
|
||||
image: 397554924689.dkr.ecr.ap-northeast-2.amazonaws.com/koreanlist
|
||||
ports:
|
||||
- 4000:3000
|
||||
links:
|
||||
- mysql
|
||||
- mongo
|
||||
env_file:
|
||||
- .env.beta.production.local
|
||||
image: wonderlandpark/koreanbots:nightly
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
3
next-env.d.ts
vendored
3
next-env.d.ts
vendored
@ -1,3 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"josa": "3.0.1",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"knex": "^0.95.8",
|
||||
"mongoose": "^5.13.4",
|
||||
"mysql": "2.18.1",
|
||||
"next": "^11.1.0",
|
||||
"next-connect": "0.10.1",
|
||||
|
||||
@ -59,7 +59,7 @@ const KoreanbotsApp = ({ Component, pageProps, err, cookie }: KoreanbotsProps):
|
||||
|
||||
return <div className={theme}>
|
||||
<DefaultSeo
|
||||
titleTemplate='%s - 한국 디스코드봇 리스트'
|
||||
titleTemplate='%s - 한국 디스코드 리스트'
|
||||
defaultTitle={TITLE}
|
||||
description={DESCRIPTION}
|
||||
openGraph={{
|
||||
|
||||
@ -11,26 +11,26 @@ const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const About:NextPage = () => {
|
||||
return <div className='pb-10'>
|
||||
<Docs title='소개' header={<h1 className='font-black text-4xl dark:text-koreanbots-blue'>“국내 디스코드 봇을 한 곳에서.”</h1>} subheader='한국 디스코드봇 리스트에서 자신의 서버에 딱 맞는 봇을 찾아보세요.'>
|
||||
<Docs title='소개' header={<h1 className='font-black text-4xl dark:text-koreanbots-blue'>“국내 디스코드의 모든 것을 한 곳에서.”</h1>} subheader='한국 디스코드 리스트에서 자신에게 필요한 디스코드의 모든 것을 찾아보세요!'>
|
||||
<Container>
|
||||
<div className='py-1'>
|
||||
<h1 className='font-bold text-5xl my-5'>소개</h1>
|
||||
<p className='text-lg'><span className='text-koreanbots-blue font-bold'>한국 디스코드봇 리스트</span>는 본인의 봇을 직접 등록하고, 봇이 필요한 유저는 필요한 봇을 카테고리별로 확인할 수 있는 플랫폼입니다.</p>
|
||||
<p className='text-lg'>자신의 봇을 등록하거나 필요한 봇을 찾아보세요!</p>
|
||||
<p className='text-lg'><span className='text-koreanbots-blue font-bold'>한국 디스코드 리스트</span>는 본인의 봇과 서버를 직접 등록하고, 유저 분은 봇 또는 서버를 카테고리별로 확인할 수 있는 플랫폼입니다.</p>
|
||||
<p className='text-lg'>자신에게 필요한 디스코드의 모든 것을 찾아보세요!</p>
|
||||
<Divider />
|
||||
<h1 className='font-bold text-5xl my-5'>특징</h1>
|
||||
<div className='grid md:grid-cols-3 gap-12 px-4 pb-5'>
|
||||
<div className='mx-auto font-normal'>
|
||||
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>하트 시스템</h2>
|
||||
<p className='text-base'>유용한 봇에 투표하는 하트 시스템으로 여러 유용한 봇이 상단에 노출될 수 있는 기회를 제공합니다.</p>
|
||||
<p className='text-base'>마음에 드는 봇이나 서버에 투표하는 하트 시스템으로 유용한 봇 또는 서버가 상단에 노출될 수 있는 기회를 제공합니다.</p>
|
||||
</div>
|
||||
<div className='mx-auto font-normal'>
|
||||
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>인증 시스템</h2>
|
||||
<p className='text-base'>디스코드 봇 인증보다 한 단계 까다로운 기준을 적용하여, 이용자분들에게 신뢰감을 줍니다.</p>
|
||||
<p className='text-base'>봇은 디스코드 봇 인증보다 한 단계 까다로운 기준을 적용하며 서버는 신뢰할 수 있는 서버를 정해, 이용자분들에게 신뢰감을 줍니다.</p>
|
||||
</div>
|
||||
<div className='mx-auto font-normal'>
|
||||
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>API 제공</h2>
|
||||
<p className='text-base'>봇 정보부터, 유저 투표 여부 확인, 봇의 svg 라벨까지.<br />다양한 API를 제공하여 커스텀할 수 있습니다!</p>
|
||||
<p className='text-base'>정보부터, 유저 투표 여부 확인, 위젯까지.<br />다양한 API를 제공하여 커스텀할 수 있습니다!</p>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
@ -39,7 +39,7 @@ const About:NextPage = () => {
|
||||
<Segment>
|
||||
<h2 className='font-semibold text-xl py-10 text-center'>
|
||||
<i className='fas fa-quote-left text-xs align-top' />
|
||||
국내 디스코드봇을 한 곳에서.
|
||||
국내 디스코드의 모든 것을 한 곳에서.
|
||||
<i className='fas fa-quote-right text-xs align-bottom' />
|
||||
</h2>
|
||||
</Segment>
|
||||
|
||||
@ -10,7 +10,7 @@ import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { get } from '@utils/Query'
|
||||
import { cleanObject, parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { AddBotSubmit, AddBotSubmitSchema } from '@utils/Yup'
|
||||
import { categories, library } from '@utils/Constants'
|
||||
import { botCategories, library } from '@utils/Constants'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { ResponseProps, SubmittedBot, Theme, User } from '@types'
|
||||
@ -71,13 +71,13 @@ const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
|
||||
}
|
||||
|
||||
if(!logged) return <Login>
|
||||
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드봇 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드봇 리스트에 등록하세요.'
|
||||
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.'
|
||||
}} />
|
||||
</Login>
|
||||
return <Container paddingTop className='py-5'>
|
||||
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드봇 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드봇 리스트에 등록하세요.'
|
||||
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.'
|
||||
}} />
|
||||
<h1 className='text-3xl font-bold'>새로운 봇 추가하기</h1>
|
||||
<div className='mt-1 mb-5'>
|
||||
@ -136,7 +136,7 @@ const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
|
||||
<Select options={library.map(el=> ({ label: el, value: el }))} handleChange={(value) => setFieldValue('library', value.value)} handleTouch={() => setFieldTouched('library', true)} />
|
||||
</Label>
|
||||
<Label For='category' label='카테고리' labelDesc='봇에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
|
||||
<Selects options={categories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
|
||||
<Selects options={botCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
|
||||
setFieldValue('category', value.map(v=> v.value))
|
||||
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
|
||||
<span className='text-gray-400 mt-1 text-sm'>봇 카드에는 앞 3개의 카테고리만 표시됩니다. 드래그하여 카테고리를 정렬하세요. <strong>반드시 해당되는 카테고리만 선택해주세요.</strong></span>
|
||||
|
||||
216
pages/addserver/[id].tsx
Normal file
216
pages/addserver/[id].tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { Form, Formik } from 'formik'
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { cleanObject, getRandom, parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { AddServerSubmitSchema, AddServerSubmit } from '@utils/Yup'
|
||||
import { serverCategories, ServerIntroList } from '@utils/Constants'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { ResponseProps, Server, ServerData, Theme, User } from '@types'
|
||||
import Forbidden from '@components/Forbidden'
|
||||
|
||||
const CheckBox = dynamic(() => import('@components/Form/CheckBox'))
|
||||
const Label = dynamic(() => import('@components/Form/Label'))
|
||||
const Login = dynamic(() => import('@components/Login'))
|
||||
const Input = dynamic(() => import('@components/Form/Input'))
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
const TextArea = dynamic(() => import('@components/Form/TextArea'))
|
||||
const Segment = dynamic(() => import('@components/Segment'))
|
||||
const Markdown = dynamic(() => import('@components/Markdown'))
|
||||
const Selects = dynamic(() => import('@components/Form/Selects'))
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Message = dynamic(() => import('@components/Message'))
|
||||
const Captcha = dynamic(() => import('@components/Captcha'))
|
||||
|
||||
const AddServer:NextPage<AddServerProps> = ({ logged, user, csrfToken, server, serverData, theme }) => {
|
||||
const [ data, setData ] = useState<ResponseProps<AddServerSubmit>>(null)
|
||||
const [ captcha, setCaptcha ] = useState(false)
|
||||
const [ touchedSumbit, setTouched ] = useState(false)
|
||||
const captchaRef = useRef<HCaptcha>()
|
||||
const router = useRouter()
|
||||
const initialValues: AddServerSubmit = {
|
||||
agree: false,
|
||||
invite: '',
|
||||
intro: '',
|
||||
desc: `<!-- 이 설명을 지우시고 원하시는 설명을 적으셔도 좋습니다! -->
|
||||
# 서버 이름
|
||||
|
||||
자신의 서버를 자유롭게 표현해보세요!
|
||||
|
||||
## ✏️ 소개
|
||||
|
||||
무엇이 목적인 서버인가요?
|
||||
|
||||
어떤 주제인가요?
|
||||
|
||||
## 💬 특징
|
||||
|
||||
- 어떤
|
||||
- 특징이
|
||||
- 있나요?`,
|
||||
category: [],
|
||||
_csrf: csrfToken,
|
||||
_captcha: 'captcha'
|
||||
}
|
||||
|
||||
function toLogin() {
|
||||
localStorage.redirectTo = window.location.href
|
||||
redirectTo(router, 'login')
|
||||
}
|
||||
|
||||
async function submitServer(id: string, value: AddServerSubmit, token: string) {
|
||||
const res = await Fetch<AddServerSubmit>(`/servers/${id}`, { method: 'POST', body: JSON.stringify(cleanObject<AddServerSubmit>({ ...value, _captcha: token })) })
|
||||
setData(res)
|
||||
}
|
||||
|
||||
if(!logged) return <Login>
|
||||
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
|
||||
}} />
|
||||
</Login>
|
||||
return <Container paddingTop className='py-5'>
|
||||
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
|
||||
}} />
|
||||
<h1 className='text-3xl font-bold'>새로운 서버 추가하기</h1>
|
||||
<div className='mt-1 mb-5'>
|
||||
안녕하세요, <span className='font-semibold'>{user.username}#{user.tag}</span>님! <a role='button' tabIndex={0} onKeyDown={toLogin} onClick={toLogin} className='text-discord-blurple cursor-pointer outline-none'>본인이 아니신가요?</a>
|
||||
</div>
|
||||
{
|
||||
data ? data.code == 200 && data.data ? <Message type='success'>
|
||||
<h2 className='text-lg font-black'>서버 등록 성공!</h2>
|
||||
<p>서버를 성공적으로 등록했습니다! 서버 페이지로 리다이랙트 됩니다! {redirectTo(router, `/servers/${router.query.id}`)}</p>
|
||||
</Message> : <Message type='error'>
|
||||
<h2 className='text-lg font-black'>{data.message || '오류가 발생했습니다.'}</h2>
|
||||
<ul className='list-disc list-inside'>
|
||||
{data.errors?.map((el, n) => <li key={n}>{el}</li>)}
|
||||
</ul>
|
||||
|
||||
</Message> : <></>
|
||||
}
|
||||
{
|
||||
server ? <Message type='warning'>
|
||||
<h2 className='text-lg font-black'>이미 등록된 서버입니다.</h2>
|
||||
</Message> :
|
||||
!serverData ? <Message type='info'>
|
||||
<h2 className='text-lg font-black'>서버에 봇이 초대되지 않았습니다.</h2>
|
||||
<p>서버를 등록하시려면 먼저 봇을 초대해야합니다.</p>
|
||||
<p>서버에 이미 봇이 초대되었다면 반영까지 최대 1분이 소요될 수 있습니다.</p>
|
||||
</Message>
|
||||
: serverData.admins.includes(user.id) || serverData.owner.includes(user.id) ? <Formik initialValues={initialValues}
|
||||
validationSchema={AddServerSubmitSchema}
|
||||
onSubmit={() => setCaptcha(true)}>
|
||||
{({ errors, touched, values, isValid, setFieldTouched, setFieldValue }) => (
|
||||
<Form>
|
||||
<div className='py-3'>
|
||||
<Message type='warning'>
|
||||
<h2 className='text-lg font-black'>등록하시기 전에 다음 사항을 확인해 주세요!</h2>
|
||||
<ul className='list-disc list-inside'>
|
||||
<li><Link href='/discord'><a rel='noreferrer' target='_blank' className='text-blue-500 hover:text-blue-600'>디스코드 서버</a></Link>에 참여를 권장드립니다.</li>
|
||||
<li>서버가 <Link href='/guidelines'><a rel='noreferrer' target='_blank' className='text-blue-500 hover:text-blue-600'>가이드라인</a></Link>을 지키고 있나요?</li>
|
||||
<li>서버에서 <strong>관리자</strong> 권한을 갖고 있는 모든 분은 삭제를 제외한 모든 행동을 할 수 있습니다.</li>
|
||||
<li>서버를 등록한 이후 봇을 추방하시게 되면 서버 정보가 웹에 업데이트 되지 않습니다.</li>
|
||||
<li>또한, 서버를 등록하게 되면 작성하신 모든 정보와 서버에서 수집된 정보는 웹과 API에 공개됩니다.</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
<Label For='agree' error={errors.agree && touched.agree ? errors.agree : null} grid={false}>
|
||||
<div className='flex items-center'>
|
||||
<CheckBox name='agree' />
|
||||
<strong className='text-sm ml-2'>해당 내용을 숙지하였으며, 등록 이후에 가이드라인을 위반할시 서버가 웹에서 삭제될 수 있다는 점을 확인했습니다.</strong>
|
||||
</div>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='id' label='서버' labelDesc='등록하시는 대상 서버 입니다.'>
|
||||
<p>
|
||||
<strong>{serverData.name}</strong>
|
||||
<br/> ID: {router.query.id}
|
||||
</p>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='category' label='카테고리' labelDesc='서버에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
|
||||
<Selects options={serverCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
|
||||
setFieldValue('category', value.map(v=> v.value))
|
||||
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
|
||||
<span className='text-gray-400 mt-1 text-sm'>서버 카드에는 앞 3개의 카테고리만 표시됩니다. 드래그하여 카테고리를 정렬하세요. <strong>반드시 해당되는 카테고리만 선택해주세요.</strong></span>
|
||||
</Label>
|
||||
<Label For='invite' label='서버 초대코드' labelDesc='서버의 초대코드를 입력해주세요. (만료되지 않는 코드로 입력해주세요!)' error={errors.invite && touched.invite ? errors.invite : null} short required>
|
||||
<div className='flex items-center'>
|
||||
discord.gg/<Input name='invite' placeholder='JEh53MQ' />
|
||||
</div>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='intro' label='서버 소개' labelDesc='서버를 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required>
|
||||
<Input name='intro' placeholder={getRandom(ServerIntroList)} />
|
||||
</Label>
|
||||
<Label For='desc' label='서버 설명' labelDesc={<>서버를 자세하게 설명해주세요! (최대 1500자)<br/>마크다운을 지원합니다!</>} error={errors.desc && touched.desc ? errors.desc : null} required>
|
||||
<TextArea max={1500} name='desc' placeholder='서버에 대해 최대한 자세히 설명해주세요!' theme={theme === 'dark' ? 'dark' : 'light'} value={values.desc} setValue={(value) => setFieldValue('desc', value)} />
|
||||
</Label>
|
||||
<Label For='preview' label='설명 미리보기' labelDesc='다음 결과는 실제와 다를 수 있습니다.'>
|
||||
<Segment>
|
||||
<Markdown text={values.desc} />
|
||||
</Segment>
|
||||
</Label>
|
||||
<Divider />
|
||||
<p className='text-base mt-2 mb-5'>
|
||||
<span className='text-red-500 font-semibold'> *</span> = 필수 항목
|
||||
</p>
|
||||
{
|
||||
captcha ? <Captcha ref={captchaRef} dark={theme === 'dark'} onVerify={(token) => {
|
||||
submitServer(router.query.id as string, values, token)
|
||||
window.scrollTo({ top: 0 })
|
||||
setCaptcha(false)
|
||||
captchaRef?.current?.resetCaptcha()
|
||||
}} /> : <>
|
||||
{
|
||||
touchedSumbit && !isValid && <div className='my-1 text-red-500 text-xs font-light'>누락되거나 잘못된 항목이 있습니다. 다시 확인해주세요.</div>
|
||||
}
|
||||
<Button type='submit' onClick={() => {
|
||||
setTouched(true)
|
||||
if(!isValid) window.scrollTo({ top: 0 })
|
||||
} }>
|
||||
<>
|
||||
<i className='far fa-paper-plane'/> 등록
|
||||
</>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
: <Forbidden />
|
||||
}
|
||||
</Container>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: NextPageContext) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const user = await get.Authorization(parsed?.token)
|
||||
const server = await get.server.load(ctx.query.id as string) || null
|
||||
const serverData = await get.serverData(ctx.query.id as string) || null
|
||||
return { props: {
|
||||
logged: !!user, user: await get.user.load(user || ''),
|
||||
csrfToken: getToken(ctx.req, ctx.res),
|
||||
server,
|
||||
serverData: (+new Date() - +new Date(serverData?.updatedAt)) < 2 * 60 * 1000 ? serverData : null
|
||||
} }
|
||||
}
|
||||
|
||||
interface AddServerProps {
|
||||
logged: boolean
|
||||
user: User
|
||||
csrfToken: string
|
||||
server: Server | null
|
||||
serverData: ServerData | null
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export default AddServer
|
||||
56
pages/addserver/index.tsx
Normal file
56
pages/addserver/index.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { parseCookie} from '@utils/Tools'
|
||||
import { RawGuild, ServerData, Theme, User } from '@types'
|
||||
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const Login = dynamic(() => import('@components/Login'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const AddBot:NextPage<AddBotProps> = ({ logged, guilds }) => {
|
||||
if(!logged) return <Login>
|
||||
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
|
||||
}} />
|
||||
</Login>
|
||||
return <Container paddingTop className='py-5'>
|
||||
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
|
||||
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
|
||||
}} />
|
||||
<h1 className='text-3xl font-bold'>새로운 서버 추가하기</h1>
|
||||
<p className='text-gray-400'>관리자이신 서버 목록입니다.</p>
|
||||
<p className='text-gray-400 pb-5'>봇을 초대한 뒤 새로고침 해주세요. 또한, 반영까지 최대 1분이 소요될 수 있습니다.</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
guilds.sort((a ,b) => (+!!b.data || 0) - (+!!a.data || 0)).map(g => (
|
||||
<ServerCard type={g.exists ? 'manage' : 'add'} server={g} key={g.id} />
|
||||
))
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
</Container>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: NextPageContext) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const user = await get.Authorization(parsed?.token)
|
||||
const guilds = (await get.userGuilds.load(user || ''))?.filter(g=> (g.permissions & 8) || g.owner).map(async g => {
|
||||
const server = (await get.server.load(g.id))
|
||||
const data = await get.serverData(g.id)
|
||||
return { ...g, ...(server || {}), ...((+new Date() - +new Date(data?.updatedAt)) < 2 * 60 * 1000 ? { data } : {}), members: data?.memberCount || null, exists: !!server }
|
||||
})
|
||||
return { props: { logged: !!user || !!guilds, user: await get.user.load(user || ''), guilds: guilds ? (await Promise.all(guilds)).filter(g => !g?.exists) : null } }
|
||||
}
|
||||
|
||||
interface AddBotProps {
|
||||
logged: boolean
|
||||
user: User
|
||||
csrfToken: string
|
||||
theme: Theme
|
||||
guilds: (RawGuild & { data: ServerData, exists?: boolean })[]
|
||||
}
|
||||
|
||||
export default AddBot
|
||||
64
pages/api/image/discord/icons/[id].ts
Normal file
64
pages/api/image/discord/icons/[id].ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { DiscordEnpoints } from '@utils/Constants'
|
||||
import { get } from '@utils/Query'
|
||||
import { ImageOptionsSchema } from '@utils/Yup'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const rateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 150,
|
||||
handler: async (_req, res) => {
|
||||
const img = await get.images.server.load(DiscordEnpoints.CDN.default(Math.floor(Math.random() * 6), { format: 'png' }))
|
||||
res.setHeader('Content-Type', 'image/png')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.send(img)
|
||||
},
|
||||
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
|
||||
skip: (_req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const Icon = RequestHandler()
|
||||
.get(rateLimiter)
|
||||
.get(async(req: ApiRequest, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL)
|
||||
const { id: param, size='256' } = req.query
|
||||
const splitted = param.split('.')
|
||||
let ext = splitted[1]
|
||||
const id = splitted[0]
|
||||
const validated = await ImageOptionsSchema.validate({ id, ext, size }, { abortEarly: false }).then(el=> el).catch(e=> {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
if(!validated) return
|
||||
|
||||
const guild = await get.server.load(id)
|
||||
let img: Buffer
|
||||
if(!guild?.icon) img = await get.images.server.load(DiscordEnpoints.CDN.default(+id % 4))
|
||||
else img = await get.images.server.load(DiscordEnpoints.CDN.guild(id, guild.icon, { format: validated.ext === 'gif' && !guild.icon.startsWith('a_') ? 'png' : validated.ext }))
|
||||
if(!img) {
|
||||
img = await get.images.server.load(DiscordEnpoints.CDN.default(+id % 4, { format: 'png', size: validated.size }))
|
||||
ext = 'png'
|
||||
}
|
||||
|
||||
|
||||
res.setHeader('Content-Type', `image/${ext}`)
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
res.send(img)
|
||||
})
|
||||
|
||||
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
size?: '128' | '256' | '512'
|
||||
}
|
||||
}
|
||||
|
||||
export default Icon
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
import { ResetBotToken, ResetBotTokenSchema } from '@utils/Yup'
|
||||
import { ResetToken, ResetTokenSchema } from '@utils/Yup'
|
||||
import { get, update } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
@ -13,7 +13,7 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
|
||||
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 })
|
||||
const validated = await ResetTokenSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
@ -30,7 +30,7 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
body: ResetBotToken
|
||||
body: ResetToken
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
|
||||
38
pages/api/v2/applications/servers/[id]/reset.ts
Normal file
38
pages/api/v2/applications/servers/[id]/reset.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
import { ResetToken, ResetTokenSchema } from '@utils/Yup'
|
||||
import { get, update } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
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 ResetTokenSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
const server = await get.server.load(req.query.id)
|
||||
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
|
||||
if(server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '서버 정보를 불러올 수 없습니다.', errors: ['서버에서 봇이 추방되었거나, 봇이 오프라인이여서 서버 정보를 갱신할 수 없습니다.'] })
|
||||
if (!(await get.serverOwners(server.id)).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
|
||||
const d = await update.resetServerToken(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: ResetToken
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default ResetApplication
|
||||
@ -2,11 +2,11 @@ import { get } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
|
||||
import { BotList } from '@types'
|
||||
import { Bot, List } from '@types'
|
||||
|
||||
const NewList = RequestHandler().get(async (_req, res) => {
|
||||
const result = await get.list.new.load(1)
|
||||
return ResponseWrapper<BotList>(res, { code: 200, data: result })
|
||||
return ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
|
||||
})
|
||||
|
||||
export default NewList
|
||||
@ -2,7 +2,7 @@ import { get } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
|
||||
import { BotList } from '@types'
|
||||
import { Bot, List } from '@types'
|
||||
import Yup from '@utils/Yup'
|
||||
|
||||
const VotesList = RequestHandler().get(async (req, res) => {
|
||||
@ -13,7 +13,7 @@ const VotesList = RequestHandler().get(async (req, res) => {
|
||||
})
|
||||
if(!page) return
|
||||
const result = await get.list.votes.load(page)
|
||||
return ResponseWrapper<BotList>(res, { code: 200, data: result })
|
||||
return ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
|
||||
})
|
||||
|
||||
export default VotesList
|
||||
40
pages/api/v2/search/all.ts
Normal file
40
pages/api/v2/search/all.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { SearchQuerySchema } from '@utils/Yup'
|
||||
|
||||
import { Bot, Server, List } from '@types'
|
||||
|
||||
const Search = RequestHandler().get(async (req: ApiRequest, res) => {
|
||||
const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: 1 })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
})
|
||||
if (!validated) return
|
||||
|
||||
let botResult: List<Bot>
|
||||
let serverResult: List<Server>
|
||||
try {
|
||||
botResult = await get.list.search.load(
|
||||
JSON.stringify({ page: validated.page, query: validated.q })
|
||||
)
|
||||
serverResult = await get.serverList.search.load(
|
||||
JSON.stringify({ page: validated.page, query: validated.q })
|
||||
)
|
||||
} catch {
|
||||
return ResponseWrapper(res, { code: 400, message: '검색 문법이 잘못되었습니다.' })
|
||||
}
|
||||
return ResponseWrapper<{ bots: Bot[], servers: Server[] }>(res, { code: 200, data: { bots: botResult?.data || [], servers: serverResult?.data || [] } })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
q?: string
|
||||
query?: string
|
||||
}
|
||||
}
|
||||
|
||||
export default Search
|
||||
@ -5,7 +5,7 @@ import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { SearchQuerySchema } from '@utils/Yup'
|
||||
|
||||
import { BotList } from '@types'
|
||||
import { Bot, List } from '@types'
|
||||
|
||||
const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => {
|
||||
const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: req.query.page })
|
||||
@ -15,7 +15,7 @@ const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResp
|
||||
})
|
||||
if (!validated) return
|
||||
|
||||
let result: BotList
|
||||
let result: List<Bot>
|
||||
try {
|
||||
result = await get.list.search.load(
|
||||
JSON.stringify({ page: validated.page, query: validated.q })
|
||||
@ -25,7 +25,7 @@ const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResp
|
||||
}
|
||||
if (result.totalPage < validated.page || result.currentPage !== validated.page)
|
||||
return ResponseWrapper(res, { code: 404, message: '검색 결과가 없습니다.' })
|
||||
else ResponseWrapper<BotList>(res, { code: 200, data: result })
|
||||
else ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
|
||||
39
pages/api/v2/search/servers.ts
Normal file
39
pages/api/v2/search/servers.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { SearchQuerySchema } from '@utils/Yup'
|
||||
|
||||
import { Server, List } from '@types'
|
||||
|
||||
const SearchServers = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => {
|
||||
const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: req.query.page })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
})
|
||||
if (!validated) return
|
||||
|
||||
let result: List<Server>
|
||||
try {
|
||||
result = await get.serverList.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<List<Server>>(res, { code: 200, data: result })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
q?: string
|
||||
query?: string
|
||||
page: string
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchServers
|
||||
175
pages/api/v2/servers/[id]/index.ts
Normal file
175
pages/api/v2/servers/[id]/index.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { MessageEmbed } from 'discord.js'
|
||||
|
||||
import { CaptchaVerify, get, put, remove, update } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import { AddServerSubmitSchema, AddServerSubmit, CsrfCaptcha, ManageServerSchema, ManageServer } from '@utils/Yup'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools'
|
||||
import { DiscordBot, discordLog } from '@utils/DiscordBot'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
|
||||
const patchLimiter = rateLimit({
|
||||
windowMs: 2 * 60 * 1000,
|
||||
max: 2,
|
||||
handler: (_req, res) => ResponseWrapper(res, { code: 429 }),
|
||||
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
|
||||
skip: (_req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
return false
|
||||
}
|
||||
})
|
||||
const Servers = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
const server = await get.server.load(req.query.id)
|
||||
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' })
|
||||
else return ResponseWrapper(res, { code: 200, data: server })
|
||||
})
|
||||
.post(async (req: PostApiRequest, 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 AddServerSubmitSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
const captcha = await CaptchaVerify(validated._captcha)
|
||||
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||
const result = await put.submitServer(user, req.query.id, validated)
|
||||
if (result === 1)
|
||||
return ResponseWrapper(res, {
|
||||
code: 400,
|
||||
message: '이미 등록된 서버 입니다.'
|
||||
})
|
||||
else if (result === 2)
|
||||
return ResponseWrapper(res, {
|
||||
code: 406,
|
||||
message: '봇이 초대되지 않았습니다.',
|
||||
errors: [
|
||||
'서버에 봇이 초대되지 않았습니다.',
|
||||
'이미 봇을 초대하셨다면, 잠시 후 다시 시도해주세요.'
|
||||
],
|
||||
})
|
||||
else if (result === 3)
|
||||
return ResponseWrapper(res, {
|
||||
code: 403,
|
||||
message: '서버의 관리자가 아닙니다.',
|
||||
errors: [
|
||||
'해당 서버를 등록할 권한이 없습니다.',
|
||||
'서버에서 관리자 권한이 있으신지 확인해주세요.'
|
||||
],
|
||||
})
|
||||
else if (result === 4)
|
||||
return ResponseWrapper(res, {
|
||||
code: 400,
|
||||
message: '올바르지 않은 초대 코드 입니다.',
|
||||
errors: [
|
||||
'올바른 초대코드를 입력하셨는지 확인해주세요'
|
||||
],
|
||||
})
|
||||
get.user.clear(user)
|
||||
await discordLog('SERVER/SUBMIT', user, new MessageEmbed().setDescription(`[${req.query.id}](${KoreanbotsEndPoints.URL.server(req.query.id)})`), {
|
||||
content: inspect(serialize(validated)),
|
||||
format: 'js'
|
||||
})
|
||||
|
||||
return ResponseWrapper(res, { code: 200, data: result })
|
||||
})
|
||||
.delete(async (req: DeleteApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const server = await get.server.load(req.query.id)
|
||||
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' })
|
||||
const data = await get.serverData(req.query.id)
|
||||
if(!data || server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '해당 서버의 정보를 불러올 수 없습니다.', errors: ['봇이 추방되었거나, 오프라인이 아닌지 확인하시고 다시 시도해주세요.'] })
|
||||
if(![data.owner, ...data.admins].includes(user)) return ResponseWrapper(res, { code: 403 })
|
||||
const userInfo = await get.user.load(user)
|
||||
if(['reported', 'blocked'].includes(server.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 서버는 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
const captcha = await CaptchaVerify(req.body._captcha)
|
||||
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||
if(req.body.name !== server.name) return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' })
|
||||
await remove.server(server.id)
|
||||
get.user.clear(user)
|
||||
await discordLog('SERVER/DELETE', user, (new MessageEmbed().setDescription(`${server.name} - [${server.id}](${KoreanbotsEndPoints.URL.bot(server.id)}))`)),
|
||||
{
|
||||
content: inspect(server),
|
||||
format: 'js'
|
||||
}
|
||||
)
|
||||
return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' })
|
||||
})
|
||||
.patch(patchLimiter).patch(async (req: PatchApiRequest, res) => {
|
||||
const server = await get.server.load(req.query.id)
|
||||
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const userInfo = await get.user.load(user)
|
||||
const data = await get.serverData(req.query.id)
|
||||
if(!data || server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '해당 서버의 정보를 불러올 수 없습니다.', errors: ['봇이 추방되었거나, 오프라인이 아닌지 확인하시고 다시 시도해주세요.'] })
|
||||
if(![data.owner, ...data.admins].includes(user) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403 })
|
||||
if(['reported', 'blocked'].includes(server.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 서버는 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
|
||||
const validated = await ManageServerSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
const invite = await DiscordBot.fetchInvite(validated.invite).catch(() => null)
|
||||
if(invite?.guild.id !== server.id) return ResponseWrapper(res, { code: 400, message: '올바르지 않은 초대코드입니다.', errors: ['입력하신 초대코드가 올바르지 않습니다. 올바른 초대코드를 입력했는지 다시 한 번 확인해주세요.'] })
|
||||
const result = await update.server(req.query.id, validated)
|
||||
if(result === 0) return ResponseWrapper(res, { code: 400 })
|
||||
else {
|
||||
get.server.clear(req.query.id)
|
||||
const embed = new MessageEmbed().setDescription(`${server.name} - ([${server.id}](${KoreanbotsEndPoints.URL.server(server.id)}))`)
|
||||
const diffData = objectDiff(
|
||||
{ intro: server.intro, invite: server.invite, category: JSON.stringify(server.category) },
|
||||
{ intro: validated.intro, invite: validated.invite, category: JSON.stringify(validated.category) },
|
||||
)
|
||||
diffData.forEach(d => {
|
||||
embed.addField(d[0], makeDiscordCodeblock(diff(d[1][0] || '', d[1][1] || ''), 'diff'))
|
||||
})
|
||||
await discordLog('SERVER/EDIT', user, embed,
|
||||
{
|
||||
content: `--- 설명\n${diff(server.desc, validated.desc, true)}`,
|
||||
format: 'diff'
|
||||
}
|
||||
)
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
interface GetApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PostApiRequest extends GetApiRequest {
|
||||
body: AddServerSubmit | null
|
||||
}
|
||||
|
||||
interface PatchApiRequest extends GetApiRequest {
|
||||
body: ManageServer | null
|
||||
}
|
||||
|
||||
interface DeleteApiRequest extends GetApiRequest {
|
||||
body: CsrfCaptcha & { name: string } | null
|
||||
}
|
||||
|
||||
export default Servers
|
||||
20
pages/api/v2/servers/[id]/owners.ts
Normal file
20
pages/api/v2/servers/[id]/owners.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { NextApiRequest } from 'next'
|
||||
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { get } from '@utils/Query'
|
||||
|
||||
const ServerOwners = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
const owners = await get.serverOwners(req.query.id)
|
||||
if(!owners) return ResponseWrapper(res, { code: 404 })
|
||||
return ResponseWrapper(res, { code: 200, data: owners })
|
||||
})
|
||||
|
||||
interface GetApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerOwners
|
||||
53
pages/api/v2/servers/[id]/report.ts
Normal file
53
pages/api/v2/servers/[id]/report.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { ReportSchema, Report} from '@utils/Yup'
|
||||
import { getReportChannel } from '@utils/DiscordBot'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000,
|
||||
max: 3,
|
||||
statusCode: 429,
|
||||
skipFailedRequests: true,
|
||||
handler: (_req, res) => ResponseWrapper(res, { code: 429 }),
|
||||
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
|
||||
skip: (_req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const ServerReport = RequestHandler().post(limiter)
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if(!user) return ResponseWrapper(res, { code: 401 })
|
||||
const server = await get.server.load(req.query.id)
|
||||
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
if(!req.body) return ResponseWrapper(res, { code: 400 })
|
||||
const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if(!validated) return
|
||||
await getReportChannel().send(`Reported by <@${user}> (${user})\nReported **${server.name}** (${server.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${req.body.description}\`\`\``, { allowedMentions: { parse: ['users'] }})
|
||||
return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' })
|
||||
})
|
||||
|
||||
|
||||
interface PostApiRequest extends NextApiRequest {
|
||||
body: Report | null
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerReport
|
||||
57
pages/api/v2/servers/[id]/vote.ts
Normal file
57
pages/api/v2/servers/[id]/vote.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
import { CaptchaVerify, get, put } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import Yup from '@utils/Yup'
|
||||
import { VOTE_COOLDOWN } from '@utils/Constants'
|
||||
|
||||
const ServerVote = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
const server = await get.ServerAuthorization(req.headers.authorization)
|
||||
if(!server) return ResponseWrapper(res, { code: 401 })
|
||||
if(req.query.id !== server) return ResponseWrapper(res, { code: 403 })
|
||||
const userID = await Yup.string().required().label('userID').validate(req.query.userID).then(el => el).catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
if(!userID) return ResponseWrapper(res, { code: 400 })
|
||||
const result = await get.vote(userID, server, 'server')
|
||||
return ResponseWrapper(res, { code: 200, data: { voted: +new Date() < result + VOTE_COOLDOWN, lastVote: result } })
|
||||
})
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if(!user) return ResponseWrapper(res, { code: 401 })
|
||||
const server = await get.server.load(req.query.id)
|
||||
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
const captcha = await CaptchaVerify(req.body._captcha)
|
||||
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||
|
||||
const vote = await put.voteServer(user, server.id)
|
||||
if(vote === null) return ResponseWrapper(res, { code: 401 })
|
||||
else if(vote === true) return ResponseWrapper(res, { code: 200 })
|
||||
else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
interface GetApiRequest extends ApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
userID: string
|
||||
}
|
||||
}
|
||||
interface PostApiRequest extends ApiRequest {
|
||||
body: {
|
||||
_captcha: string
|
||||
_csrf: string
|
||||
}
|
||||
}
|
||||
export default ServerVote
|
||||
65
pages/api/widget/servers/[type]/[id].ts
Normal file
65
pages/api/widget/servers/[type]/[id].ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { ServerWidgetOptionsSchema } from '@utils/Yup'
|
||||
|
||||
import { badgen } from 'badgen'
|
||||
import { get } from '@utils/Query'
|
||||
import { ServerBadgeType, 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 validated = await ServerWidgetOptionsSchema.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.server.load(validated.id)
|
||||
|
||||
if (!data) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
|
||||
const userImage = !data.icon
|
||||
? null
|
||||
: await get.images.user.load(
|
||||
DiscordEnpoints.CDN.guild(data.id, data.icon, { format: 'png', size: 128 })
|
||||
)
|
||||
const img =
|
||||
userImage ||
|
||||
(await get.images.user.load(
|
||||
DiscordEnpoints.CDN.default(+data.id % 5, { format: 'png', size: 128 })
|
||||
))
|
||||
res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
|
||||
const badgeData = {
|
||||
...ServerBadgeType(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: {
|
||||
type: string
|
||||
id: string
|
||||
style?: string
|
||||
scale?: string
|
||||
icon?: string
|
||||
}
|
||||
}
|
||||
|
||||
export default Widget
|
||||
@ -11,7 +11,7 @@ import { getJosaPicker } from 'josa'
|
||||
import { get } from '@utils/Query'
|
||||
import { checkUserFlag, cleanObject, makeBotURL, parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { ManageBot, ManageBotSchema } from '@utils/Yup'
|
||||
import { categories, library } from '@utils/Constants'
|
||||
import { botCategories, library } from '@utils/Constants'
|
||||
import { Bot, Theme, User } from '@types'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
@ -113,7 +113,7 @@ const ManageBotPage:NextPage<ManageBotProps> = ({ bot, user, csrfToken, theme })
|
||||
<Select value={{ label: bot.lib, value: bot.lib }} options={library.map(el=> ({ label: el, value: el }))} handleChange={(value) => setFieldValue('library', value.value)} handleTouch={() => setFieldTouched('library', true)} />
|
||||
</Label>
|
||||
<Label For='category' label='카테고리' labelDesc='봇에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
|
||||
<Selects options={categories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
|
||||
<Selects options={botCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
|
||||
setFieldValue('category', value.map(v=> v.value))
|
||||
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
|
||||
<span className='text-gray-400 mt-1 text-sm'>봇 카드에는 앞 3개의 카테고리만 표시됩니다. 드래그하여 카테고리를 정렬하세요. <strong>반드시 해당되는 카테고리만 선택해주세요.</strong></span>
|
||||
|
||||
@ -88,7 +88,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
|
||||
}
|
||||
</div>
|
||||
<div className='lg:flex w-full'>
|
||||
<div className='w-full text-center lg:w-1/4'>
|
||||
<div className='w-full text-center lg:w-2/12'>
|
||||
<DiscordAvatar
|
||||
userID={data.id}
|
||||
size={256}
|
||||
@ -108,7 +108,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
|
||||
<h1 className='mb-2 mt-3 text-4xl font-bold' style={bg ? { color: 'white' } : {}}>
|
||||
{data.name}{' '}
|
||||
{checkBotFlag(data.flags, 'trusted') ? (
|
||||
<Tooltip placement='bottom' overlay='해당 봇은 한국 디스코드봇 리스트에서 엄격한 기준을 통과한 봇입니다!'>
|
||||
<Tooltip placement='bottom' overlay='해당 봇은 한국 디스코드 리스트에서 엄격한 기준을 통과한 봇입니다!'>
|
||||
<span className='text-koreanbots-blue text-3xl'>
|
||||
<i className='fas fa-award' />
|
||||
</span>
|
||||
@ -121,10 +121,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
|
||||
{
|
||||
data.state === 'ok' && <LongButton
|
||||
newTab
|
||||
href={
|
||||
data.url ||
|
||||
`https://discordapp.com/oauth2/authorize?client_id=${data.id}&scope=bot&permissions=0`
|
||||
}
|
||||
href={`/bots/${router.query.id}/invite`}
|
||||
>
|
||||
<h4 className='whitespace-nowrap'>
|
||||
<i className='fas fa-user-plus text-discord-blurple' /> 초대하기
|
||||
@ -204,7 +201,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
|
||||
<h2 className='3xl mb-2 mt-2 font-bold'>카테고리</h2>
|
||||
<div className='flex flex-wrap'>
|
||||
{data.category.map(el => (
|
||||
<Tag key={el} text={el} href={`/categories/${el}`} />
|
||||
<Tag key={el} text={el} href={`/bots/categories/${el}`} />
|
||||
))}
|
||||
</div>
|
||||
<h2 className='3xl mb-2 mt-2 font-bold'>제작자</h2>
|
||||
@ -320,9 +317,9 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
|
||||
{
|
||||
checkBotFlag(data.flags, 'hackerthon') ? <Segment className='mt-10'>
|
||||
<h1 className='text-3xl font-semibold'>
|
||||
<i className='fas fa-trophy mr-4 my-2 text-yellow-300' /> 해당 봇은 한국 디스코드봇 리스트 해커톤 수상작품입니다!
|
||||
<i className='fas fa-trophy mr-4 my-2 text-yellow-300' /> 해당 봇은 한국 디스코드 리스트 해커톤 수상작품입니다!
|
||||
</h1>
|
||||
<p>해당 봇은 한국 디스코드봇 리스트 주최로 진행되었던 "한국 디스코드봇 리스트 제1회 해커톤"에서 우수한 성적을 거둔 봇입니다.</p>
|
||||
<p>해당 봇은 한국 디스코드 리스트 주최로 진행되었던 "한국 디스코드 리스트 제1회 해커톤"에서 우수한 성적을 거둔 봇입니다.</p>
|
||||
<p>자세한 내용은 <a className='text-blue-500 hover:text-blue-400' href='https://blog.koreanbots.dev/first-hackathon-results/'>해당 글</a>을 확인해주세요.</p>
|
||||
</Segment> : ''
|
||||
}
|
||||
|
||||
20
pages/bots/[id]/invite.tsx
Normal file
20
pages/bots/[id]/invite.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { NextPage, GetServerSideProps } from 'next'
|
||||
import NotFound from 'pages/404'
|
||||
import { get } from '@utils/Query'
|
||||
import { Bots } from '@utils/Mongo'
|
||||
import { getYYMMDD } from '@utils/Tools'
|
||||
const Invite: NextPage = () => <NotFound />
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const data = await get.bot.load(ctx.query.id as string)
|
||||
if(!data) return { props: {} }
|
||||
const record = await Bots.updateOne({ _id: data.id, 'inviteMetrix.day': getYYMMDD() }, { $inc: { 'inviteMetrix.$.count': 1 } })
|
||||
if(record.n === 0) await Bots.findByIdAndUpdate(data.id, { $push: { inviteMetrix: { count: 1 } } }, { upsert: true })
|
||||
ctx.res.statusCode = 307
|
||||
ctx.res.setHeader('Location', data.url || `https://discordapp.com/oauth2/authorize?client_id=${data.id}&scope=bot&permissions=0`)
|
||||
return {
|
||||
props: {}
|
||||
}
|
||||
}
|
||||
|
||||
export default Invite
|
||||
@ -35,7 +35,7 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
||||
const router = useRouter()
|
||||
if(!data?.id) return <NotFound />
|
||||
if(!user) return <Login>
|
||||
<NextSeo title={data.name} description={`한국 디스코드봇 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
|
||||
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
|
||||
images: [
|
||||
{
|
||||
url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }),
|
||||
@ -49,7 +49,7 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
||||
|
||||
if((checkBotFlag(data.flags, 'trusted') || checkBotFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== router.query.id) router.push(`/bots/${data.vanity}/vote?csrfToken=${csrfToken}`)
|
||||
return <Container paddingTop className='py-10'>
|
||||
<NextSeo title={data.name} description={`한국 디스코드봇 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
|
||||
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
|
||||
images: [
|
||||
{
|
||||
url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }),
|
||||
|
||||
@ -3,7 +3,7 @@ import dynamic from 'next/dynamic'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { BotList } from '@types'
|
||||
import { Bot, List } from '@types'
|
||||
import { botCategoryListArgumentSchema } from '@utils/Yup'
|
||||
import NotFound from 'pages/404'
|
||||
import { useEffect, useState } from 'react'
|
||||
@ -27,7 +27,7 @@ const Category: NextPage<CategoryProps> = ({ data, query }) => {
|
||||
}, [])
|
||||
if(!data || data.data.length === 0 || data.totalPage < Number(query.page)) return <NotFound message={data?.data.length === 0 ? '해당 카테고리에 해당되는 봇이 존재하지 않습니다.' : null} />
|
||||
return <>
|
||||
<Hero header={`${query.category} 카테고리 봇들`} description={`다양한 "${query.category}" 카테고리의 봇들을 만나보세요.`} />
|
||||
<Hero type='bots' header={`${query.category} 카테고리 봇들`} description={`다양한 "${query.category}" 카테고리의 봇들을 만나보세요.`} />
|
||||
{
|
||||
query.category === 'NSFW' && !nsfw ? <NSFW onClick={() => setNSFW(true)} onDisableClick={() => localStorage.nsfw = true} />
|
||||
: <Container>
|
||||
@ -43,7 +43,7 @@ const Category: NextPage<CategoryProps> = ({ data, query }) => {
|
||||
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> )
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname={`/categories/${query.category}`} />
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname={`/bots/categories/${query.category}`} />
|
||||
<Advertisement />
|
||||
</Container>
|
||||
}
|
||||
@ -51,7 +51,7 @@ const Category: NextPage<CategoryProps> = ({ data, query }) => {
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: Context) => {
|
||||
let data: BotList
|
||||
let data: List<Bot>
|
||||
if(!ctx.query.page) ctx.query.page = '1'
|
||||
const validate = await botCategoryListArgumentSchema.validate(ctx.query).then(el => el).catch(() => null)
|
||||
if(!validate || isNaN(Number(ctx.query.page))) data = null
|
||||
@ -65,7 +65,7 @@ export const getServerSideProps = async (ctx: Context) => {
|
||||
}
|
||||
|
||||
interface CategoryProps {
|
||||
data: BotList
|
||||
data: List<Bot>
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { NextPage } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
import { categories, categoryIcon } from '@utils/Constants'
|
||||
import { botCategories, botCategoryIcon } from '@utils/Constants'
|
||||
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
@ -12,19 +12,19 @@ const Segment = dynamic(() => import('@components/Segment'))
|
||||
|
||||
const Categories:NextPage = () => {
|
||||
return <Container paddingTop>
|
||||
<NextSeo title='전체 카테고리' description='한국 디스코드봇 리스트의 전체 카테고리입니다.' />
|
||||
<NextSeo title='전체 카테고리' description='한국 디스코드 리스트의 전체 카테고리입니다.' />
|
||||
<h1 className='text-2xl font-bold mt-2 mb-5'>전체 카테고리</h1>
|
||||
<Segment className='mb-10'>
|
||||
<div className='text-center flex flex-wrap mt-1.5'>
|
||||
{
|
||||
categories.map(t => <Tag key={t} text={<>
|
||||
botCategories.map(t => <Tag key={t} text={<>
|
||||
{
|
||||
{ '빗금 명령어': <span className='fa-stack' style={{ fontSize: '1em', height: '1.2em', lineHeight: '1em', width: '20px', verticalAlign: 'middle' }}>
|
||||
<i className='fas fa-square fa-stack-1x fa-md' />
|
||||
<i className='fas fa-slash fa-rotate-90 fa-xs fa-stack-1x fa-inverse' style={{ fontSize: '0.3rem' }} />
|
||||
</span> }[t] ?? <i className={categoryIcon[t]} />
|
||||
</span> }[t] ?? <i className={botCategoryIcon[t]} />
|
||||
} {t}
|
||||
</>} href={`/categories/${t}`} dark bigger /> )
|
||||
</>} href={`/bots/categories/${t}`} dark bigger /> )
|
||||
}
|
||||
</div>
|
||||
</Segment>
|
||||
72
pages/bots/index.tsx
Normal file
72
pages/bots/index.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { NextPage } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { Bot, List } from '@types'
|
||||
import * as Query from '@utils/Query'
|
||||
import LongButton from '@components/LongButton'
|
||||
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const BotCard = dynamic(() => import('@components/BotCard'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
|
||||
const Index: NextPage<IndexProps> = ({ votes, newBots, trusted }) => {
|
||||
return (
|
||||
<>
|
||||
<Hero type='bots' />
|
||||
<Container className='pb-10'>
|
||||
<Advertisement />
|
||||
<h1 className='text-3xl font-bold mt-10 mb-2'>
|
||||
<i className='far fa-heart mr-3 text-pink-600' /> 하트 랭킹
|
||||
</h1>
|
||||
<p className='text-base'>하트를 많이 받은 봇들의 순위입니다!</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
votes.data.map(bot=> <BotCard key={bot.id} bot={bot} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={votes.totalPage} currentPage={votes.currentPage} pathname='/bots/list/votes' />
|
||||
<Advertisement />
|
||||
<h1 className='text-3xl font-bold mb-2'>
|
||||
<i className='fa fa-check mr-3 mt-10 text-green-500' /> 신뢰된 봇
|
||||
</h1>
|
||||
<p className='text-base'>한국 디스코드 리스트에서 인증받은 신뢰할 수 있는 봇들입니다!!</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
trusted.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<h1 className='text-3xl font-bold mt-20 mb-2'>
|
||||
<i className='far fa-star mr-3 text-yellow-500' /> 새로운 봇
|
||||
</h1>
|
||||
<p className='text-base'>최근에 한국 디스코드 리스트에 추가된 따끈따끈한 봇입니다.</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
newBots.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<LongButton href='/bots/list/new' center>더보기</LongButton>
|
||||
<Advertisement />
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps = async() => {
|
||||
const votes = await Query.get.list.votes.load(1)
|
||||
const newBots = await Query.get.list.new.load(1)
|
||||
const trusted = await Query.get.list.trusted.load(1)
|
||||
|
||||
return { props: { votes, newBots, trusted }}
|
||||
|
||||
}
|
||||
|
||||
interface IndexProps {
|
||||
votes: List<Bot>
|
||||
newBots: List<Bot>
|
||||
trusted: List<Bot>
|
||||
}
|
||||
|
||||
export default Index
|
||||
@ -2,7 +2,7 @@ import { NextPage } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { BotList } from '@types'
|
||||
import { Bot, List } from '@types'
|
||||
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
@ -12,7 +12,7 @@ const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
|
||||
const New:NextPage<NewProps> = ({ data }) => {
|
||||
return <>
|
||||
<Hero header='새로운 봇' description='최근에 한국 디스코드봇 리스트에 추가된 봇들입니다!' />
|
||||
<Hero type='bots' header='새로운 봇' description='최근에 한국 디스코드 리스트에 추가된 봇들입니다!' />
|
||||
<Container className='pb-10'>
|
||||
<Advertisement />
|
||||
<ResponsiveGrid>
|
||||
@ -35,7 +35,7 @@ export const getServerSideProps = async () => {
|
||||
}
|
||||
|
||||
interface NewProps {
|
||||
data: BotList
|
||||
data: List<Bot>
|
||||
}
|
||||
|
||||
export default New
|
||||
@ -3,10 +3,10 @@ import { useRouter } from 'next/router'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { BotList } from '@types'
|
||||
import { Bot, List } from '@types'
|
||||
import { get }from '@utils/Query'
|
||||
|
||||
import NotFound from '../404'
|
||||
import NotFound from '../../404'
|
||||
import { PageCount } from '@utils/Yup'
|
||||
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
@ -20,7 +20,7 @@ const Votes:NextPage<VotesProps> = ({ data }) => {
|
||||
const router = useRouter()
|
||||
if(!data || data.data.length === 0 || data.totalPage < Number(router.query.page)) return <NotFound />
|
||||
return <>
|
||||
<Hero header='하트 랭킹' description='하트를 많이 받은 봇들의 순위입니다!'/>
|
||||
<Hero type='bots' header='하트 랭킹' description='하트를 많이 받은 봇들의 순위입니다!'/>
|
||||
<section id='list'>
|
||||
<Container className='pb-10'>
|
||||
<Advertisement />
|
||||
@ -29,14 +29,14 @@ const Votes:NextPage<VotesProps> = ({ data }) => {
|
||||
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> )
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/list/votes' />
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/bots/list/votes' />
|
||||
<Advertisement />
|
||||
</Container>
|
||||
</section>
|
||||
</>
|
||||
}
|
||||
export const getServerSideProps = async (ctx:Context) => {
|
||||
let data: BotList
|
||||
let data: List<Bot>
|
||||
if(!ctx.query.page) ctx.query.page = '1'
|
||||
const validate = await PageCount.validate(ctx.query.page).then(el => el).catch(() => null)
|
||||
if(!validate || isNaN(Number(ctx.query.page))) data = null
|
||||
@ -49,7 +49,7 @@ export const getServerSideProps = async (ctx:Context) => {
|
||||
}
|
||||
|
||||
interface VotesProps {
|
||||
data: BotList
|
||||
data: List<Bot>
|
||||
}
|
||||
|
||||
interface Context extends NextPageContext {
|
||||
88
pages/bots/search.tsx
Normal file
88
pages/bots/search.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import type { FC } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { List, Bot } from '@types'
|
||||
import { get } from '@utils/Query'
|
||||
import { SearchQuerySchema } from '@utils/Yup'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
|
||||
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const BotCard = dynamic(() => import('@components/BotCard'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
const LongButton = dynamic(() => import('@components/LongButton'))
|
||||
const Redirect = dynamic(() => import('@components/Redirect'))
|
||||
|
||||
const SearchComponent: FC<{data: List<Bot>, query: URLQuery }> = ({ data, query }) => {
|
||||
return <div className='py-10'>
|
||||
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'>검색 결과가 없습니다.</h1> :
|
||||
<>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
data.data.map(el => <BotCard key={el.id} bot={el as Bot} /> )
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/search' searchParams={query} />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
const Search:NextPage<SearchProps> = ({ botData, query }) => {
|
||||
if(!query?.q) return <Redirect text={false} to='/' />
|
||||
return <>
|
||||
<Hero type='bots' header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
|
||||
<Container>
|
||||
<section id='list'>
|
||||
<Advertisement />
|
||||
<h1 className='text-4xl font-bold'>봇</h1>
|
||||
<SearchComponent data={botData} query={query} />
|
||||
<h1 className='text-2xl font-bold py-10'>서버를 찾으시나요?</h1>
|
||||
<LongButton center href={KoreanbotsEndPoints.URL.searchServer(query.q)}>서버 검색 결과 보기</LongButton>
|
||||
<Advertisement />
|
||||
</section>
|
||||
</Container>
|
||||
</>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async(ctx: Context) => {
|
||||
if(ctx.query.query && !ctx.query.q) ctx.query.q = ctx.query.query
|
||||
if(!ctx.query?.q) {
|
||||
ctx.res.statusCode = 301
|
||||
ctx.res.setHeader('Location', '/')
|
||||
return { props: {} }
|
||||
}
|
||||
if(!ctx.query.page) ctx.query.page = '1'
|
||||
const validate = await SearchQuerySchema.validate(ctx.query).then(el => el).catch(() => null)
|
||||
if(!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } }
|
||||
else {
|
||||
return {
|
||||
props: {
|
||||
botData: await get.list.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
|
||||
query: ctx.query
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface SearchProps {
|
||||
botData?: List<Bot>
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface Context extends NextPageContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
q?: string
|
||||
query?: string
|
||||
page?: string
|
||||
}
|
||||
|
||||
export default Search
|
||||
@ -60,7 +60,7 @@ const BotApplication: NextPage<BotApplicationProps> = ({ user, spec, bot, theme,
|
||||
</a>
|
||||
</Link>
|
||||
<h1 className='text-3xl font-bold'>봇 설정</h1>
|
||||
<p className='text-gray-400'>한국 디스코드봇 리스트 API에 사용할 정보를 이곳에서 설정하실 수 있습니다.</p>
|
||||
<p className='text-gray-400'>한국 디스코드 리스트 API에 사용할 정보를 이곳에서 설정하실 수 있습니다.</p>
|
||||
<div className='lg:flex pt-6'>
|
||||
<div className='lg:w-1/5'>
|
||||
<DiscordAvatar userID={bot.id} />
|
||||
@ -111,7 +111,7 @@ const BotApplication: NextPage<BotApplicationProps> = ({ user, spec, bot, theme,
|
||||
<Form>
|
||||
<div className='mb-2'>
|
||||
<h3 className='font-bold mb-1'>웹훅 URL</h3>
|
||||
<p className='text-gray-400 text-sm mb-1'>웹훅을 이용하여 다양한 한국 디스코드봇 리스트의 봇에 발생하는 이벤트를 받아볼 수 있습니다.</p>
|
||||
<p className='text-gray-400 text-sm mb-1'>웹훅을 이용하여 다양한 한국 디스코드 리스트의 봇에 발생하는 이벤트를 받아볼 수 있습니다.</p>
|
||||
<Input name='webhook' placeholder='https://webhook.kbots.link' />
|
||||
{touched.webhook && errors.webhook ? <div className='text-red-500 text-xs font-light mt-1'>{errors.webhook}</div> : null}
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import { NextSeo } from 'next-seo'
|
||||
import { get } from '@utils/Query'
|
||||
import { parseCookie } from '@utils/Tools'
|
||||
|
||||
import { Bot, User } from '@types'
|
||||
import { Bot, Server, User } from '@types'
|
||||
|
||||
const Application = dynamic(() => import('@components/Application'))
|
||||
const DeveloperLayout = dynamic(() => import('@components/DeveloperLayout'))
|
||||
@ -13,16 +13,23 @@ const Login = dynamic(() => import('@components/Login'))
|
||||
|
||||
const Applications: NextPage<ApplicationsProps> = ({ user }) => {
|
||||
if(!user) return <Login>
|
||||
<NextSeo title='한디리 개발자' description='한국 디스코드봇 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' />
|
||||
<NextSeo title='한디리 개발자' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' />
|
||||
</Login>
|
||||
return <DeveloperLayout enabled='applications'>
|
||||
<h1 className='text-3xl font-bold'>나의 봇</h1>
|
||||
<p className='text-gray-400'>한국 디스코드봇 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.</p>
|
||||
<p className='text-gray-400'>한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.</p>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2'>
|
||||
{
|
||||
(user.bots as Bot[]).map(bot => <Application key={bot.id} id={bot.id} name={bot.name} type='bot' />)
|
||||
}
|
||||
</div>
|
||||
<h1 className='text-3xl font-bold mt-10'>나의 서버</h1>
|
||||
<p className='text-gray-400'>한국 디스코드 리스트 API를 활용하여 서버에 다양한 기능을 추가해보세요.</p>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2'>
|
||||
{
|
||||
(user.servers as Server[]).map(bot => <Application key={bot.id} id={bot.id} name={bot.name} type='server' />)
|
||||
}
|
||||
</div>
|
||||
</DeveloperLayout>
|
||||
|
||||
}
|
||||
|
||||
163
pages/developers/applications/servers/[id].tsx
Normal file
163
pages/developers/applications/servers/[id].tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import useCopyClipboard from 'react-use-clipboard'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
import { Server, BotSpec, ResponseProps, Theme } from '@types'
|
||||
|
||||
import NotFound from 'pages/404'
|
||||
import Link from 'next/link'
|
||||
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
const DeveloperLayout = dynamic(() => import('@components/DeveloperLayout'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
const Message = dynamic(() => import('@components/Message'))
|
||||
const Modal = dynamic(() => import('@components/Modal'))
|
||||
|
||||
const ServerApplication: NextPage<ServerApplicationProps> = ({ user, spec, server, theme, csrfToken }) => {
|
||||
const router = useRouter()
|
||||
const [ data, setData ] = useState<ResponseProps<unknown>>(null)
|
||||
const [ modalOpened, setModalOpen ] = useState(false)
|
||||
const [ showToken, setShowToken ] = useState(false)
|
||||
const [ tokenCopied, setTokenCopied ] = useCopyClipboard(spec?.token, {
|
||||
successDuration: 1000
|
||||
})
|
||||
// async function updateApplication(d: DeveloperBot) {
|
||||
// const res = await Fetch(`/applications/bots/${bot.id}`, {
|
||||
// method: 'PATCH',
|
||||
// body: JSON.stringify(cleanObject(d))
|
||||
// })
|
||||
// setData(res)
|
||||
// }
|
||||
|
||||
async function resetToken() {
|
||||
const res = await Fetch<{ token: string }>(`/applications/servers/${server.id}/reset`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token: spec.token, _csrf: csrfToken })
|
||||
})
|
||||
setData(res)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
if(!user) {
|
||||
localStorage.redirectTo = window.location.href
|
||||
redirectTo(router, 'login')
|
||||
return
|
||||
}
|
||||
if(!server) return <NotFound />
|
||||
return <DeveloperLayout enabled='applications'>
|
||||
<Link href='/developers/applications'>
|
||||
<a className='text-blue-500 hover:text-blue-400'>
|
||||
<i className='fas fa-arrow-left' /> 돌아가기
|
||||
</a>
|
||||
</Link>
|
||||
<h1 className='text-3xl font-bold'>서버 설정</h1>
|
||||
<p className='text-gray-400'>한국 디스코드 리스트 API에 사용할 정보를 이곳에서 설정하실 수 있습니다.</p>
|
||||
{
|
||||
spec ? <>
|
||||
<div className='lg:flex pt-6'>
|
||||
<div className='lg:w-1/5'>
|
||||
<ServerIcon id={server.id} />
|
||||
</div>
|
||||
<div className='lg:w-4/5 relative'>
|
||||
<div className='mt-4'>
|
||||
{
|
||||
!data ? '' : data.code === 200 ?
|
||||
<Message type='success'>
|
||||
<h2 className='text-lg font-black'>수정 성공!</h2>
|
||||
<p>서버 정보를 저장했습니다.</p>
|
||||
</Message> : <Message type='error'>
|
||||
<h2 className='text-lg font-black'>{data.message}</h2>
|
||||
<ul className='list-disc list-inside'>
|
||||
{
|
||||
data.errors?.map((el, i)=> <li key={i}>{el}</li>)
|
||||
}
|
||||
</ul>
|
||||
</Message>
|
||||
}
|
||||
</div>
|
||||
<div className='grid text-left px-6'>
|
||||
<h2 className='text-3xl font-bold mb-2 mt-3'>{server.name}</h2>
|
||||
<h3 className='text-lg font-semibold'>서버 토큰</h3>
|
||||
<pre className='text-sm overflow-x-scroll w-full'>{showToken ? spec.token : '******************'}</pre>
|
||||
<div className='pt-3 pb-6'>
|
||||
<Button onClick={() => setShowToken(!showToken)}>{showToken ? '숨기기' : '보기'}</Button>
|
||||
<Button onClick={setTokenCopied} className={tokenCopied ? 'bg-green-400 text-white' : null}>{tokenCopied ? '복사됨' : '복사'}</Button>
|
||||
<Button onClick={()=> setModalOpen(true)}>재발급</Button>
|
||||
<Modal isOpen={modalOpened} onClose={() => setModalOpen(false)} dark={theme === 'dark'} header='정말로 토큰을 재발급하시겠습니까?'>
|
||||
<p>기존에 사용중이시던 토큰은 더 이상 사용하실 수 없습니다</p>
|
||||
<div className='text-right pt-6'>
|
||||
<Button className='bg-gray-500 text-white hover:opacity-90' onClick={()=> setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={async ()=> {
|
||||
const res = await resetToken()
|
||||
if(res.data?.token) spec.token = res.data.token
|
||||
setModalOpen(false)
|
||||
}}>재발급</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
{/* <Formik validationSchema={DeveloperBotSchema} initialValues={{
|
||||
webhook: spec.webhook || '',
|
||||
_csrf: csrfToken
|
||||
}}
|
||||
onSubmit={(data) => updateApplication(data)}>
|
||||
{({ errors, touched }) => (
|
||||
<Form>
|
||||
<div className='mb-2'>
|
||||
<h3 className='font-bold mb-1'>웹훅 URL</h3>
|
||||
<p className='text-gray-400 text-sm mb-1'>웹훅을 이용하여 다양한 한국 디스코드 리스트의 봇에 발생하는 이벤트를 받아볼 수 있습니다.</p>
|
||||
<Input name='webhook' placeholder='https://webhook.kbots.link' />
|
||||
{touched.webhook && errors.webhook ? <div className='text-red-500 text-xs font-light mt-1'>{errors.webhook}</div> : null}
|
||||
</div>
|
||||
<Button type='submit'><i className='far fa-save'/> 저장</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</> : <div className='mt-5'>
|
||||
<Message type='error'>
|
||||
<h2 className='text-lg font-black'>서버 정보를 불러올 수 없습니다.</h2>
|
||||
<p>서버에서 봇이 추방되었거나, 봇이 오프라인이여서 서버 정보를 갱신할 수 없습니다.</p>
|
||||
</Message>
|
||||
</div>
|
||||
}
|
||||
</DeveloperLayout>
|
||||
|
||||
}
|
||||
|
||||
interface ServerApplicationProps {
|
||||
user: string
|
||||
spec: BotSpec
|
||||
server: Server
|
||||
csrfToken: string
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: Context) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const user = await get.Authorization(parsed?.token) || ''
|
||||
const server = await get.server.load(ctx.query.id)
|
||||
return {
|
||||
props: { user, spec: server?.state === 'unreachable' ? null : await get.serverSpec(ctx.query.id, user), server, csrfToken: getToken(ctx.req, ctx.res) }
|
||||
}
|
||||
}
|
||||
|
||||
interface Context extends NextPageContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default ServerApplication
|
||||
@ -1,53 +1,45 @@
|
||||
import { NextPage } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { BotList } from '@types'
|
||||
import { Bot, List, Server } from '@types'
|
||||
import * as Query from '@utils/Query'
|
||||
import LongButton from '@components/LongButton'
|
||||
|
||||
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const BotCard = dynamic(() => import('@components/BotCard'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
const LongButton = dynamic(() => import('@components/LongButton'))
|
||||
|
||||
const Index: NextPage<IndexProps> = ({ votes, newBots, trusted }) => {
|
||||
const Index: NextPage<IndexProps> = ({ bots, servers }) => {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Container className='pb-10'>
|
||||
<Advertisement />
|
||||
<h1 className='text-3xl font-bold mt-10 mb-2'>
|
||||
<i className='far fa-heart mr-3 text-pink-600' /> 하트 랭킹
|
||||
<i className='fas fa-robot mr-5 text-koreanbots-blue' /> 봇 리스트
|
||||
</h1>
|
||||
<p className='text-base'>하트를 많이 받은 봇들의 순위입니다!</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
votes.data.map(bot=> <BotCard key={bot.id} bot={bot} />)
|
||||
bots.data.slice(0, 8).map(bot=> <BotCard key={bot.id} bot={bot} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={votes.totalPage} currentPage={votes.currentPage} pathname='/list/votes' />
|
||||
<LongButton href='/bots' center>봇 리스트 바로가기</LongButton>
|
||||
<Advertisement />
|
||||
<h1 className='text-3xl font-bold mb-2'>
|
||||
<i className='fa fa-check mr-3 mt-10 text-green-500' /> 신뢰된 봇
|
||||
<h1 className='text-3xl font-bold mt-10 mb-2'>
|
||||
<i className='fas fa-users mr-5 text-koreanbots-blue' /> 서버 리스트
|
||||
</h1>
|
||||
<p className='text-base'>KOREANBOTS에서 인증받은 신뢰할 수 있는 봇들입니다!!</p>
|
||||
<p className='text-base'>하트를 많이 받은 서버들의 순위입니다!</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
trusted.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
|
||||
servers.data.slice(0, 8).map(bot=> <ServerCard key={bot.id} type='list' server={bot} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<h1 className='text-3xl font-bold mt-20 mb-2'>
|
||||
<i className='far fa-star mr-3 text-yellow-500' /> 새로운 봇
|
||||
</h1>
|
||||
<p className='text-base'>최근에 한국 디스코드봇 리스트에 추가된 따끈따끈한 봇입니다.</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
newBots.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<LongButton href='/list/new' center>더보기</LongButton>
|
||||
<LongButton href='/servers' center>서버 리스트 바로가기</LongButton>
|
||||
<Advertisement />
|
||||
</Container>
|
||||
</>
|
||||
@ -55,18 +47,16 @@ const Index: NextPage<IndexProps> = ({ votes, newBots, trusted }) => {
|
||||
}
|
||||
|
||||
export const getServerSideProps = async() => {
|
||||
const votes = await Query.get.list.votes.load(1)
|
||||
const newBots = await Query.get.list.new.load(1)
|
||||
const trusted = await Query.get.list.trusted.load(1)
|
||||
const bots = await Query.get.list.votes.load(1)
|
||||
const servers = await Query.get.serverList.votes.load(1)
|
||||
|
||||
return { props: { votes, newBots, trusted }}
|
||||
return { props: { bots, servers }}
|
||||
|
||||
}
|
||||
|
||||
interface IndexProps {
|
||||
votes: BotList
|
||||
newBots: BotList
|
||||
trusted: BotList
|
||||
bots: List<Bot>
|
||||
servers: List<Server>
|
||||
}
|
||||
|
||||
export default Index
|
||||
|
||||
@ -15,7 +15,7 @@ const Opensource: NextPage<OpensourceProps> = ({ packageJson, mainLicense, licen
|
||||
<span className='text-koreanbots-blue'>한디리</span>
|
||||
<span><i className='text-red-500 fas fa-heart ml-2 mr-3' /> </span>
|
||||
<span>오픈소스</span>
|
||||
</h1>} subheader='한국 디스코드봇은 오픈소스 프로젝트이며, 다양한 오픈소스 프로젝트가 사용되었습니다.'>
|
||||
</h1>} subheader='한국 디스코드 리스트는 오픈소스 프로젝트이며, 다양한 오픈소스 프로젝트가 사용되었습니다.'>
|
||||
<h1 className='text-3xl font-bold'>소스코드</h1>
|
||||
<a href='https://github.com/koreanbots/koreanbots'><i className='fab fa-github'/>Github</a>
|
||||
<h2 className='text-2xl font-semibold my-2'>라이선스</h2>
|
||||
|
||||
@ -6,7 +6,7 @@ import { useRouter } from 'next/router'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { parseCookie } from '@utils/Tools'
|
||||
import { Bot, SubmittedBot, User } from '@types'
|
||||
import { Bot, Server, SubmittedBot, User } from '@types'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
|
||||
@ -14,6 +14,7 @@ const Container = dynamic(() => import('@components/Container'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
const BotCard = dynamic(() => import('@components/BotCard'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const SubmittedBotCard = dynamic(() => import('@components/SubmittedBotCard'))
|
||||
const Login = dynamic(() => import('@components/Login'))
|
||||
|
||||
@ -50,6 +51,17 @@ const Panel:NextPage<PanelProps> = ({ logged, user, submits, csrfToken }) => {
|
||||
</ResponsiveGrid>
|
||||
}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<h2 className='text-3xl font-bold'>나의 서버</h2>
|
||||
{
|
||||
user.servers.length === 0 ? <h2 className='text-xl'>소유한 서버가 없습니다.</h2> :
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
(user.servers as Server[]).map(server=> <ServerCard key={server.id} server={server} type='manage' />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<h2 className='text-3xl font-bold'>봇 심사이력</h2>
|
||||
{
|
||||
|
||||
@ -112,7 +112,7 @@ const PendingBot: NextPage<PendingBotProps> = ({ data }) => {
|
||||
<h2 className='3xl mb-2 mt-2 font-bold'>카테고리</h2>
|
||||
<div className='flex flex-wrap'>
|
||||
{data.category.map(el => (
|
||||
<Tag key={el} text={el} href={`/categories/${el}`} />
|
||||
<Tag key={el} text={el} href={`/bots/categories/${el}`} />
|
||||
))}
|
||||
</div>
|
||||
<h2 className='3xl mb-2 mt-2 font-bold'>제작자</h2>
|
||||
|
||||
@ -1,40 +1,56 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import type { FC } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { BotList } from '@types'
|
||||
import { List, Bot, Server } from '@types'
|
||||
import { get } from '@utils/Query'
|
||||
import { SearchQuerySchema } from '@utils/Yup'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
|
||||
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const BotCard = dynamic(() => import('@components/BotCard'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
const LongButton = dynamic(() => import('@components/LongButton'))
|
||||
const Redirect = dynamic(() => import('@components/Redirect'))
|
||||
|
||||
const Search:NextPage<SearchProps> = ({ data, query }) => {
|
||||
if(!query?.q) return <Redirect text={false} to='/' />
|
||||
return <>
|
||||
<Hero header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
|
||||
<section id='list'>
|
||||
<Container>
|
||||
<Advertisement />
|
||||
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'>검색 결과가 없습니다.</h1> :
|
||||
<>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> )
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/search' searchParams={query} />
|
||||
</>
|
||||
const SearchComponent: FC<{data: List<Bot|Server>, query: URLQuery, type: 'bot' | 'server' }> = ({ data, query, type }) => {
|
||||
return <div className='py-20'>
|
||||
<h1 className='text-4xl font-bold'>{type === 'bot' ? '봇' : '서버'}</h1>
|
||||
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'>검색 결과가 없습니다.</h1> :
|
||||
<>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
data.data.map(el => ( type === 'bot' ? <BotCard key={el.id} bot={el as Bot} /> : <ServerCard key={el.id} type='list' server={el as Server} /> ))
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
{
|
||||
data.totalPage !== 1 && <LongButton center href={type === 'bot' ? KoreanbotsEndPoints.URL.searchBot(query.q) : KoreanbotsEndPoints.URL.searchServer(query.q)}>
|
||||
더보기
|
||||
</LongButton>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
const Search:NextPage<SearchProps> = ({ botData, serverData, priority, query }) => {
|
||||
if(!query?.q) return <Redirect text={false} to='/' />
|
||||
const list: ('bot'|'server')[] = [ 'bot', 'server' ]
|
||||
return <>
|
||||
<Hero type={priority ? priority === 'bot' ? 'bots' : 'servers' : 'all'} header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
|
||||
<Container>
|
||||
<section id='list'>
|
||||
<Advertisement />
|
||||
</Container>
|
||||
</section>
|
||||
{
|
||||
(priority === 'server' ? list.reverse() : list).map(el => <SearchComponent key={el} data={el === 'bot' ? botData : serverData} query={query} type={el} />)
|
||||
}
|
||||
<Advertisement />
|
||||
</section>
|
||||
</Container>
|
||||
</>
|
||||
}
|
||||
|
||||
@ -45,23 +61,26 @@ export const getServerSideProps = async(ctx: Context) => {
|
||||
ctx.res.setHeader('Location', '/')
|
||||
return { props: {} }
|
||||
}
|
||||
let data: BotList
|
||||
if(!ctx.query.page) ctx.query.page = '1'
|
||||
const validate = await SearchQuerySchema.validate(ctx.query).then(el => el).catch(() => null)
|
||||
if(!validate || isNaN(Number(ctx.query.page))) data = null
|
||||
else data = await get.list.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null)
|
||||
|
||||
return {
|
||||
props: {
|
||||
data,
|
||||
query: ctx.query
|
||||
if(!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } }
|
||||
else {
|
||||
return {
|
||||
props: {
|
||||
botData: await get.list.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
|
||||
serverData: await get.serverList.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
|
||||
query: ctx.query,
|
||||
priority: validate.priority || null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface SearchProps {
|
||||
data: BotList,
|
||||
botData?: List<Bot>
|
||||
serverData?: List<Server>
|
||||
priority?: 'bot' | 'server'
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
|
||||
@ -14,17 +14,17 @@ const BODY = '중요도:\n설명:\n\n영향을 줄 수 있는 경우:'
|
||||
const Security: NextPage<SecurityProps> = ({ bugReports }) => {
|
||||
return <Docs
|
||||
header='버그 바운티 프로그램'
|
||||
description='한국 디스코드봇 리스트는 보안을 최우선으로 생각합니다.'
|
||||
description='한국 디스코드 리스트는 보안을 최우선으로 생각합니다.'
|
||||
>
|
||||
<h1 className='mb-3 text-3xl font-bold text-koreanbots-blue'>소개</h1>
|
||||
<p>한국 디스코드봇 리스트는 보안을 우선으로 생각합니다. 보안 버그 제보를 장려하기위해 보안 관련 취약점을 제보해주신 분께 소정의 보상을 지급해드립니다.</p>
|
||||
<p>한국 디스코드 리스트는 보안을 우선으로 생각합니다. 보안 버그 제보를 장려하기위해 보안 관련 취약점을 제보해주신 분께 소정의 보상을 지급해드립니다.</p>
|
||||
<h1 className='mt-6 mb-3 text-3xl font-bold text-koreanbots-blue'>규칙</h1>
|
||||
<ul className='list-disc list-inside'>
|
||||
<li>자신이 소유하고 있는 계정과 봇에서만 테스트해야합니다. 절대로 다른 유저에게 영향을 주어서는 안됩니다.</li>
|
||||
<li>한국 디스코드봇 리스트의 서비스에 피해를 끼치는 활동을 해서는 안됩니다. 예) 무차별 대입, DDoS, DoS 등</li>
|
||||
<li>한국 디스코드 리스트의 서비스에 피해를 끼치는 활동을 해서는 안됩니다. 예) 무차별 대입, DDoS, DoS 등</li>
|
||||
<li>취약점을 찾기 위해 스캐너와 같은 자동화 도구는 사용하지 마세요.</li>
|
||||
<li>발견한 문제에 대한 모든 정보는 보안팀이 완벽하게 조사하고 해결하기 전까지는 절대로 제3자에게 공개/공유해서는 안됩니다.</li>
|
||||
<li>한국 디스코드봇 리스트는 제보된 문제에 관한 모든 정보를 공개할 권한을 가집니다.</li>
|
||||
<li>한국 디스코드 리스트는 제보된 문제에 관한 모든 정보를 공개할 권한을 가집니다.</li>
|
||||
</ul>
|
||||
<h1 className='mt-6 mb-3 text-3xl font-bold text-koreanbots-blue'>범위</h1>
|
||||
<ul className='list-disc list-inside'>
|
||||
@ -34,7 +34,7 @@ const Security: NextPage<SecurityProps> = ({ bugReports }) => {
|
||||
</ul>
|
||||
<h1 className='mt-6 mb-3 text-3xl font-bold text-koreanbots-blue'>취약점에 포함되지 않는 사항</h1>
|
||||
<ul className='list-disc list-inside'>
|
||||
<li>이미 한국 디스코드봇 리스트 내부에서 해당 취약점을 인지하고 있는 경우</li>
|
||||
<li>이미 한국 디스코드 리스트 내부에서 해당 취약점을 인지하고 있는 경우</li>
|
||||
<li>Brute force 공격</li>
|
||||
<li>Clickjacking</li>
|
||||
<li>DoS 공격</li>
|
||||
|
||||
196
pages/servers/[id]/edit.tsx
Normal file
196
pages/servers/[id]/edit.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { Form, Formik } from 'formik'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
import { getJosaPicker } from 'josa'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { checkUserFlag, cleanObject, getRandom, makeServerURL, parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { ManageServer, ManageServerSchema } from '@utils/Yup'
|
||||
import { serverCategories, ServerIntroList } from '@utils/Constants'
|
||||
import { Server, Theme, User } from '@types'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
|
||||
import NotFound from 'pages/404'
|
||||
|
||||
const Label = dynamic(() => import('@components/Form/Label'))
|
||||
const Input = dynamic(() => import('@components/Form/Input'))
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
const Redirect = dynamic(() => import('@components/Redirect'))
|
||||
const TextArea = dynamic(() => import('@components/Form/TextArea'))
|
||||
const Segment = dynamic(() => import('@components/Segment'))
|
||||
const Markdown = dynamic(() => import('@components/Markdown'))
|
||||
const Selects = dynamic(() => import('@components/Form/Selects'))
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
const Message = dynamic(() => import('@components/Message'))
|
||||
const Modal = dynamic(() => import('@components/Modal'))
|
||||
const Captcha = dynamic(() => import('@components/Captcha'))
|
||||
const Login = dynamic(() => import('@components/Login'))
|
||||
const Forbidden = dynamic(() => import('@components/Forbidden'))
|
||||
|
||||
const ManageServerPage:NextPage<ManageServerProps> = ({ server, user, owners, csrfToken, theme }) => {
|
||||
const [ data, setData ] = useState(null)
|
||||
const [ deleteModal, setDeleteModal ] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function submitServer(value: ManageServer) {
|
||||
const res = await Fetch(`/servers/${server.id}`, { method: 'PATCH', body: JSON.stringify(cleanObject<ManageServer>(value)) })
|
||||
setData(res)
|
||||
}
|
||||
|
||||
if(!server) return <NotFound />
|
||||
if(!user) return <Login>
|
||||
<NextSeo title='서버 정보 수정하기' description='서버의 정보를 수정합니다.'/>
|
||||
</Login>
|
||||
if(!(owners as User[]).find(el => el.id === user.id) && !checkUserFlag(user.flags, 'staff')) return <Forbidden />
|
||||
return <Container paddingTop className='pt-5 pb-10'>
|
||||
<NextSeo title={`${server.name} 수정하기`} description='서버의 정보를 수정합니다.'/>
|
||||
<h1 className='text-3xl font-bold mb-8'>서버 관리하기</h1>
|
||||
<Formik initialValues={cleanObject({
|
||||
invite: server.invite,
|
||||
intro: server.intro,
|
||||
desc: server.desc,
|
||||
category: server.category,
|
||||
_csrf: csrfToken
|
||||
})}
|
||||
validationSchema={ManageServerSchema}
|
||||
onSubmit={submitServer}>
|
||||
{({ errors, touched, values, setFieldTouched, setFieldValue }) => (
|
||||
<Form>
|
||||
<div className='md:flex text-center md:text-left'>
|
||||
<ServerIcon id={server.id} className='md:mx-1 mx-auto'/>
|
||||
<div className='md:w-2/3 px-8 py-6'>
|
||||
<h1 className='text-3xl font-bold'>{server.name}</h1>
|
||||
<h2>ID: {server.id}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
data ? data.code === 200 ? <div className='mt-4'>
|
||||
<Redirect to={makeServerURL(server)}>
|
||||
<Message type='success'>
|
||||
<h2 className='text-lg font-black'>정보를 저장했습니다.</h2>
|
||||
<p>반영까지는 시간이 조금 걸릴 수 있습니다!</p>
|
||||
</Message>
|
||||
</Redirect>
|
||||
</div> : <div className='mt-4'>
|
||||
<Message type='error'>
|
||||
<h2 className='text-lg font-black'>{data.message || '오류가 발생했습니다.'}</h2>
|
||||
<ul className='list-disc list-inside'>
|
||||
{data.errors?.map((el, n) => <li key={n}>{el}</li>)}
|
||||
</ul>
|
||||
</Message>
|
||||
</div> : ''
|
||||
}
|
||||
<Label For='category' label='카테고리' labelDesc='서버에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
|
||||
<Selects options={serverCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
|
||||
setFieldValue('category', value.map(v=> v.value))
|
||||
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
|
||||
<span className='text-gray-400 mt-1 text-sm'>서버 카드에는 앞 3개의 카테고리만 표시됩니다. 드래그하여 카테고리를 정렬하세요. <strong>반드시 해당되는 카테고리만 선택해주세요.</strong></span>
|
||||
</Label>
|
||||
<Label For='invite' label='서버 초대코드' labelDesc='서버의 초대코드를 입력해주세요. (만료되지 않는 코드로 입력해주세요!)' error={errors.invite && touched.invite ? errors.invite : null} short required>
|
||||
<div className='flex items-center'>
|
||||
discord.gg/<Input name='invite' placeholder='JEh53MQ' />
|
||||
</div>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='intro' label='서버 소개' labelDesc='서버를 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required>
|
||||
<Input name='intro' placeholder={getRandom(ServerIntroList)} />
|
||||
</Label>
|
||||
<Label For='desc' label='서버 설명' labelDesc={<>서버를 자세하게 설명해주세요! (최대 1500자)<br/>마크다운을 지원합니다!</>} error={errors.desc && touched.desc ? errors.desc : null} required>
|
||||
<TextArea max={1500} name='desc' placeholder='서버에 대해 최대한 자세히 설명해주세요!' theme={theme === 'dark' ? 'dark' : 'light'} value={values.desc} setValue={(value) => setFieldValue('desc', value)} />
|
||||
</Label>
|
||||
<Label For='preview' label='설명 미리보기' labelDesc='다음 결과는 실제와 다를 수 있습니다.'>
|
||||
<Segment>
|
||||
<Markdown text={values.desc} />
|
||||
</Segment>
|
||||
</Label>
|
||||
<Divider />
|
||||
<p className='text-base mt-2 mb-5'>
|
||||
<span className='text-red-500 font-semibold'> *</span> = 필수 항목
|
||||
</p>
|
||||
<Button type='submit' onClick={() => window.scrollTo({ top: 0 })}>
|
||||
<>
|
||||
<i className='far fa-save'/> 저장
|
||||
</>
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
{
|
||||
(checkUserFlag(user.flags, 'staff') || server.owner?.id === user.id) && <div className='py-4'>
|
||||
<Divider />
|
||||
<h2 className='text-2xl font-semibold pb-2'>위험구역</h2>
|
||||
<p className='text-gray-400 mb-3'>관리자 추가나 소유권 이전은 웹사이트에서 진행하실 수 없습니다. 디스코드 서버 내에서 "관리자" 권한을 부여하시거나 서버의 소유권을 이전하시면 됩니다.</p>
|
||||
<Segment>
|
||||
<div className='lg:flex items-center'>
|
||||
<div className='flex-grow py-1'>
|
||||
<h3 className='text-lg font-semibold'>서버 삭제하기</h3>
|
||||
<p className='text-gray-400'>서버를 삭제하게 되면 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setDeleteModal(true)} className='h-10 bg-red-500 hover:opacity-80 text-white lg:w-1/8'><i className='fas fa-trash' /> 서버 삭제하기</Button>
|
||||
<Modal full header={`${server.name} 삭제하기`} isOpen={deleteModal} dark={theme === 'dark'} onClose={() => setDeleteModal(false)} closeIcon>
|
||||
<Formik initialValues={{ name: '', _captcha: '', _csrf: csrfToken }} onSubmit={async (v) => {
|
||||
const res = await Fetch(`/servers/${server.id}`, { method: 'DELETE', body: JSON.stringify(v) })
|
||||
if(res.code === 200) {
|
||||
alert('성공적으로 삭제하였습니다.')
|
||||
redirectTo(router, '/')
|
||||
}
|
||||
else alert(res.message)
|
||||
}}>
|
||||
{
|
||||
({ values, setFieldValue }) => <Form>
|
||||
<Message type='warning'>
|
||||
<p>서버를 삭제하게 되면 되돌릴 수 없습니다.<br/>하트 수를 포함한 모든 서버 정보가 영구적으로 삭제됩니다.</p>
|
||||
<p>계속 하시려면 서버의 이름 <strong>{server.name}</strong>{getJosaPicker('을')(server.name)} 입력해주세요.</p>
|
||||
</Message>
|
||||
<div className='py-4'>
|
||||
<Input name='name' placeholder={server.name} />
|
||||
</div>
|
||||
<Captcha dark={theme === 'dark'} onVerify={(k) => setFieldValue('_captcha', k)} />
|
||||
<Button disabled={values.name !== server.name || !values._captcha} className={`mt-4 bg-red-500 text-white ${values.name !== server.name || !values._captcha ? 'opacity-80' : 'hover:opacity-80'}`} type='submit'><i className='fas fa-trash' /> 삭제</Button>
|
||||
</Form>
|
||||
}
|
||||
</Formik>
|
||||
</Modal>
|
||||
</div>
|
||||
</Segment>
|
||||
</div>
|
||||
}
|
||||
</Container>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: Context) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const user = await get.Authorization(parsed?.token)
|
||||
return { props: {
|
||||
server: await get.server.load(ctx.query.id),
|
||||
user: await get.user.load(user || ''),
|
||||
owners: await get.serverOwners(ctx.query.id),
|
||||
csrfToken: getToken(ctx.req, ctx.res)
|
||||
} }
|
||||
}
|
||||
|
||||
interface ManageServerProps {
|
||||
server: Server
|
||||
user: User
|
||||
owners: User[]
|
||||
csrfToken: string
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
interface Context extends NextPageContext {
|
||||
query: Query
|
||||
}
|
||||
|
||||
interface Query extends ParsedUrlQuery {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default ManageServerPage
|
||||
319
pages/servers/[id]/index.tsx
Normal file
319
pages/servers/[id]/index.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import { useRouter } from 'next/router'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import Tooltip from 'rc-tooltip'
|
||||
|
||||
import { SnowflakeUtil } from 'discord.js'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
import { Server, Theme, User } from '@types'
|
||||
|
||||
import { DiscordEnpoints, DSKR_BOT_ID, KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { get, safeImageHost } from '@utils/Query'
|
||||
import Day from '@utils/Day'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { checkBotFlag, checkServerFlag, checkUserFlag, formatNumber, parseCookie } from '@utils/Tools'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
|
||||
import NotFound from '../../404'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Image = dynamic(() => import('@components/Image'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
const Tag = dynamic(() => import('@components/Tag'))
|
||||
const Owner = dynamic(() => import('@components/Owner'))
|
||||
const Segment = dynamic(() => import('@components/Segment'))
|
||||
const LongButton = dynamic(() => import('@components/LongButton'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const Markdown = dynamic(() => import ('@components/Markdown'))
|
||||
const Message = dynamic(() => import('@components/Message'))
|
||||
const Modal = dynamic(() => import('@components/Modal'))
|
||||
|
||||
const Servers: NextPage<ServersProps> = ({ data, desc, date, user, theme }) => {
|
||||
const [ emojisModal, setEmojisModal ] = useState(false)
|
||||
const [ ownersModal, setOwnersModal ] = useState(false)
|
||||
const [ owners, setOwners ] = useState<User[]>(null)
|
||||
const bg = checkBotFlag(data?.flags, 'trusted') && data?.banner
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
if(data) Fetch<User[]>(`/servers/${data.id}/owners`).then(async res => {
|
||||
if(res?.code === 200) setOwners(res.data)
|
||||
})
|
||||
}, [ data ])
|
||||
if (!data?.id) return <NotFound />
|
||||
return <div style={bg ? { background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${data.bg}") center top / cover no-repeat fixed` } : {}}>
|
||||
<Container paddingTop className='py-10'>
|
||||
<NextSeo
|
||||
title={data.name}
|
||||
description={data.intro}
|
||||
twitter={{
|
||||
cardType: 'summary_large_image'
|
||||
}}
|
||||
openGraph={{
|
||||
images: [
|
||||
{
|
||||
url: KoreanbotsEndPoints.OG.server(data.id, data.name, data.intro, data.category, [formatNumber(data.votes), formatNumber(data.members)]),
|
||||
width: 2048,
|
||||
height: 1170,
|
||||
alt: 'Server Preview Image'
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
{
|
||||
data.state === 'blocked' ? <div className='pb-40'>
|
||||
<Message type='error'>
|
||||
<h2 className='text-lg font-black'>해당 서버는 관리자에 의해 삭제되었습니다.</h2>
|
||||
</Message>
|
||||
</div>
|
||||
: <>
|
||||
<div className='w-full pb-2'>
|
||||
{
|
||||
data.state === 'unreachable' ? <Message type='error'>
|
||||
<h2 className='text-lg font-black'>서버 정보를 갱신할 수 없습니다.</h2>
|
||||
<p>서버에서 봇이 추방되었거나, 봇이 오프라인이여서 서버 정보를 갱신할 수 없습니다.</p>
|
||||
{
|
||||
owners?.find(el => el.id === user?.id) && <>
|
||||
<h3 className='text-md font-bold pt-2'>서버 괸리자시군요!</h3>
|
||||
<p>봇을 서버에서 추방하셨다면 <a className='text-blue-600 hover:text-blue-500 cursor-pointer' href={`${DiscordEnpoints.InviteApplication(DSKR_BOT_ID, {}, 'bot', null, data.id)}&disable_guild_select=true`}>이곳</a>을 눌러 봇을 다시 초대해주세요!</p>
|
||||
</>
|
||||
}
|
||||
</Message> :
|
||||
data.state === 'reported' ?
|
||||
<Message type='error'>
|
||||
<h2 className='text-lg font-black'>해당 서버는 신고가 접수되어, 관리자에 의해 잠금 상태입니다.</h2>
|
||||
<p>해당 서버를 주의해주세요.</p>
|
||||
<p>서버 관리자 분은 <Link href='/guidelines'><a className='text-blue-500 hover:text-blue-400'>가이드라인</a></Link>에 대한 위반사항을 확인해주시고 <Link href='/discord'><a className='text-blue-500 hover:text-blue-400'>디스코드 서버</a></Link>로 문의해주세요.</p>
|
||||
</Message> : ''
|
||||
}
|
||||
</div>
|
||||
<div className='lg:flex w-full'>
|
||||
<div className='w-full text-center lg:w-2/12'>
|
||||
<ServerIcon
|
||||
id={data.id}
|
||||
size={256}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-grow px-5 py-12 w-full text-center lg:w-5/12 lg:text-left'>
|
||||
<h1 className='mb-2 mt-3 text-4xl font-bold' style={bg ? { color: 'white' } : {}}>
|
||||
{data.name}{' '}
|
||||
{checkServerFlag(data.flags, 'trusted') ? (
|
||||
<Tooltip placement='bottom' overlay='해당 서버는 한국 디스코드 리스트에서 엄격한 기준을 통과한 서버 입니다!'>
|
||||
<span className='text-koreanbots-blue text-3xl'>
|
||||
<i className='fas fa-award' />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : ''}
|
||||
</h1>
|
||||
<p className={`${bg ? 'text-gray-300' : 'dark:text-gray-300 text-gray-800'} text-base`}>{data.intro}</p>
|
||||
</div>
|
||||
<div className='w-full lg:w-1/4'>
|
||||
{
|
||||
['ok', 'unreachable'].includes(data.state) && <LongButton
|
||||
newTab
|
||||
href={`/servers/${router.query.id}/join`}
|
||||
>
|
||||
<h4 className='whitespace-nowrap'>
|
||||
<i className='fas fa-user-plus text-discord-blurple' /> 참가하기
|
||||
</h4>
|
||||
</LongButton>
|
||||
}
|
||||
<Link href={`/servers/${router.query.id}/vote`}>
|
||||
<LongButton>
|
||||
<h4>
|
||||
<i className='fas fa-heart text-red-600' /> 하트 추가
|
||||
</h4>
|
||||
<span className='ml-1 px-2 text-center text-black dark:text-gray-400 text-sm bg-little-white-hover dark:bg-very-black rounded-lg'>
|
||||
{formatNumber(data.votes)}
|
||||
</span>
|
||||
</LongButton>
|
||||
</Link>
|
||||
{
|
||||
(owners?.find(el => el.id === user?.id) || checkUserFlag(user?.flags, 'staff')) && <>
|
||||
<LongButton href={`/servers/${data.id}/edit`}>
|
||||
<h4>
|
||||
<i className='fas fa-cogs' /> 관리하기
|
||||
</h4>
|
||||
</LongButton>
|
||||
{/* <LongButton onClick={async() => {
|
||||
const res = await Fetch(`/servers/${data.id}/stats`, { method: 'PATCH'} )
|
||||
if(res.code !== 200) return alert(res.message)
|
||||
else window.location.reload()
|
||||
}}>
|
||||
<h4>
|
||||
<i className='fas fa-sync' /> 정보 갱신하기
|
||||
</h4>
|
||||
</LongButton> */}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className='px-5' />
|
||||
<div className='hidden lg:block'>
|
||||
<Advertisement />
|
||||
</div>
|
||||
<div className='lg:flex lg:flex-row-reverse' style={bg ? { color: 'white' } : {}}>
|
||||
<div className='mb-1 w-full lg:w-1/4'>
|
||||
<h2 className='3xl mb-2 font-bold'>정보</h2>
|
||||
<div className='grid gap-4 grid-cols-2 px-4 py-4 text-black dark:text-gray-400 dark:bg-discord-black bg-little-white rounded-sm'>
|
||||
<div>
|
||||
<i className='fas fa-users' /> 멤버 수
|
||||
</div>
|
||||
<div>{data.members || 'N/A'}</div>
|
||||
<div>
|
||||
<i className='far fa-gem' /> 부스트 티어
|
||||
</div>
|
||||
<div>{typeof data.boostTier === 'number' ? `${data.boostTier}레벨` : 'N/A'}</div>
|
||||
<div>
|
||||
<i className='fas fa-calendar-day' /> 서버 생성일
|
||||
</div>
|
||||
<div>{Day(date).fromNow(false)}</div>
|
||||
{
|
||||
checkServerFlag(data.flags, 'discord_partnered') ?
|
||||
<Tooltip overlay='해당 서버는 디스코드 파트너 입니다.'>
|
||||
<div className='col-span-2'>
|
||||
<i className='fas fa-infinity text-discord-blurple' /> 디스코드 파트너
|
||||
</div>
|
||||
</Tooltip>
|
||||
: ''
|
||||
}
|
||||
{
|
||||
checkServerFlag(data.flags, 'verified') ?
|
||||
<Tooltip overlay='해당 서버는 디스코드에서 인증된 서버입니다.'>
|
||||
<div className='col-span-2'>
|
||||
<i className='fas fa-check text-discord-blurple' /> 디스코드 인증됨
|
||||
</div>
|
||||
</Tooltip>
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<h2 className='3xl mb-2 mt-2 font-bold'>카테고리</h2>
|
||||
<div className='flex flex-wrap'>
|
||||
{data.category.map(el => (
|
||||
<Tag key={el} text={el} href={`/servers/categories/${el}`} />
|
||||
))}
|
||||
</div>
|
||||
{
|
||||
data.emojis.length !== 0 && <>
|
||||
<h2 className='3xl mb-2 mt-2 font-bold'>이모지</h2>
|
||||
<div className='flex flex-wrap'>
|
||||
{
|
||||
data.emojis.slice(0, 5).map(el => <Image src={el.url} key={el.name} className='h-8 m-1' />)
|
||||
}
|
||||
{
|
||||
data.emojis.length > 5 && <Tag className='cursor-pointer' onClick={() => setEmojisModal(true)} text={`+${data.emojis.length - 5}개`} />
|
||||
}
|
||||
</div>
|
||||
<Modal header='이모지 전체보기' closeIcon isOpen={emojisModal} onClose={() => setEmojisModal(false)}
|
||||
full dark={theme === 'dark'}>
|
||||
<strong>{data.emojis.length}</strong>개의 이모지가 있습니다.
|
||||
<div className='flex flex-wrap'>
|
||||
{
|
||||
data.emojis.map(el => <Tooltip zIndex={1000} key={el.name} placement='top' overlay={`:${el.name}:`} mouseLeaveDelay={0}>
|
||||
<div>
|
||||
<Image src={el.url} className='h-8 m-1' />
|
||||
</div>
|
||||
</Tooltip>)
|
||||
}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
}
|
||||
<h2 className='3xl mb-2 mt-2 font-bold'>소유자</h2>
|
||||
{
|
||||
data.owner && <Owner
|
||||
key={data.owner.id}
|
||||
id={data.owner.id}
|
||||
tag={data.owner.tag}
|
||||
username={data.owner.username}
|
||||
/>
|
||||
}
|
||||
<LongButton onClick={() => setOwnersModal(true)}>관리자 전체보기</LongButton>
|
||||
<Modal header='관리자 전체보기' closeIcon isOpen={ownersModal} onClose={() => setOwnersModal(false)}
|
||||
full dark={theme === 'dark'}>
|
||||
<div className='grid gap-x-1 grid-rows-1 md:grid-cols-2'>
|
||||
{owners ? owners.map(el => (
|
||||
<Owner
|
||||
key={el.id}
|
||||
id={el.id}
|
||||
tag={el.tag}
|
||||
username={el.username}
|
||||
crown={el.id === data.owner?.id}
|
||||
/>
|
||||
)) : <strong>불러오는 중...</strong>}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className='list grid'>
|
||||
<Link href={`/servers/${router.query.id}/report`}>
|
||||
<a className='text-red-600 hover:underline cursor-pointer' aria-hidden='true'>
|
||||
<i className='far fa-flag' /> 신고하기
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
<Advertisement size='tall' />
|
||||
</div>
|
||||
<div className='w-full lg:pr-5 lg:w-3/4'>
|
||||
<Segment className='my-4'>
|
||||
<Markdown text={desc}/>
|
||||
</Segment>
|
||||
<Advertisement />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</Container>
|
||||
</div>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: Context) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const data = await get.server.load(ctx.query.id)
|
||||
if(!data) return {
|
||||
props: {
|
||||
data
|
||||
}
|
||||
}
|
||||
const desc = safeImageHost(data.desc)
|
||||
const user = await get.Authorization(parsed?.token)
|
||||
if((checkServerFlag(data.flags, 'trusted') || checkServerFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== ctx.query.id) {
|
||||
ctx.res.statusCode = 301
|
||||
ctx.res.setHeader('Location', `/servers/${data.vanity}`)
|
||||
return {
|
||||
props: {}
|
||||
}
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
data,
|
||||
desc,
|
||||
date: SnowflakeUtil.deconstruct(data.id ?? '0').date.toJSON(),
|
||||
user: await get.user.load(user || ''),
|
||||
csrfToken: getToken(ctx.req, ctx.res)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default Servers
|
||||
|
||||
|
||||
|
||||
interface ServersProps {
|
||||
data: Server
|
||||
desc: string
|
||||
date: Date
|
||||
user: User
|
||||
theme: Theme
|
||||
csrfToken: string
|
||||
}
|
||||
interface Context extends NextPageContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
id: string
|
||||
}
|
||||
19
pages/servers/[id]/join.tsx
Normal file
19
pages/servers/[id]/join.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { NextPage, GetServerSideProps } from 'next'
|
||||
import NotFound from 'pages/404'
|
||||
import { get } from '@utils/Query'
|
||||
import { DiscordEnpoints } from '@utils/Constants'
|
||||
const Join: NextPage = () => <NotFound />
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const data = await get.server.load(ctx.query.id as string)
|
||||
if(!data) return { props: {} }
|
||||
// // const record = await Bots.updateOne({ _id: data.id, 'inviteMetrix.day': getYYMMDD() }, { $inc: { 'inviteMetrix.$.count': 1 } })
|
||||
// if(record.n === 0) await Bots.findByIdAndUpdate(data.id, { $push: { inviteMetrix: { count: 1 } } }, { upsert: true })
|
||||
ctx.res.statusCode = 307
|
||||
ctx.res.setHeader('Location', DiscordEnpoints.ServerInvite(data.invite))
|
||||
return {
|
||||
props: {}
|
||||
}
|
||||
}
|
||||
|
||||
export default Join
|
||||
140
pages/servers/[id]/report.tsx
Normal file
140
pages/servers/[id]/report.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { NextPage } from 'next'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
|
||||
import { Server, CsrfContext, ResponseProps, User } from '@types'
|
||||
import { get } from '@utils/Query'
|
||||
import { makeServerURL, parseCookie } from '@utils/Tools'
|
||||
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import NotFound from 'pages/404'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import { DMCA, TextField } from '@components/ReportTemplate'
|
||||
import { useState } from 'react'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { ReportSchema } from '@utils/Yup'
|
||||
import { getJosaPicker } from 'josa'
|
||||
import { serverReportCats } from '@utils/Constants'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Message = dynamic(() => import('@components/Message'))
|
||||
const Login = dynamic(() => import('@components/Login'))
|
||||
|
||||
const ReportServer: NextPage<ReportServerProps> = ({ data, user, csrfToken }) => {
|
||||
const [ reportRes, setReportRes ] = useState<ResponseProps<unknown>>(null)
|
||||
if(!data?.id) return <NotFound />
|
||||
if(!user) return <Login>
|
||||
<NextSeo title='신고하기' />
|
||||
</Login>
|
||||
return <Container paddingTop className='py-10'>
|
||||
<NextSeo title={`${data.name} 신고하기`} />
|
||||
<Link href={makeServerURL(data)}>
|
||||
<a className='text-blue-500 hover:opacity-80'><i className='fas fa-arrow-left mt-3 mb-3' /> <strong>{data.name}</strong>{getJosaPicker('로')(data.name)} 돌아가기</a>
|
||||
</Link>
|
||||
{
|
||||
reportRes?.code === 200 ? <Message type='success'>
|
||||
<h2 className='text-lg font-semibold'>성공적으로 제출하였습니다!</h2>
|
||||
<p>더 자세한 설명이 필요할 수 있습니다. <strong>반드시 <a className='text-blue-600 hover:text-blue-500' href='/discord'>공식 디스코드</a>에 참여해주세요!!</strong></p>
|
||||
</Message> : <Formik onSubmit={async (body) => {
|
||||
const res = await Fetch(`/servers/${data.id}/report`, { method: 'POST', body: JSON.stringify(body) })
|
||||
setReportRes(res)
|
||||
}} validationSchema={ReportSchema} initialValues={{
|
||||
category: null,
|
||||
description: '',
|
||||
_csrf: csrfToken
|
||||
}}>
|
||||
{
|
||||
({ errors, touched, values, setFieldValue }) => (
|
||||
<Form>
|
||||
<div className='mb-5'>
|
||||
{
|
||||
reportRes && <div className='my-5'>
|
||||
<Message type='error'>
|
||||
<h2 className='text-lg font-semibold'>{reportRes.message}</h2>
|
||||
<ul className='list-disc'>
|
||||
{reportRes.errors?.map((el, n) => <li key={n}>{el}</li>)}
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
}
|
||||
<h3 className='font-bold'>신고 구분</h3>
|
||||
<p className='text-gray-400 text-sm mb-1'>해당되는 항목을 선택해주세요.</p>
|
||||
{
|
||||
serverReportCats.map(el =>
|
||||
<div key={el}>
|
||||
<label>
|
||||
<Field type='radio' name='category' value={el} className='mr-1.5 py-2' />
|
||||
{el}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='mt-1 text-red-500 text-xs font-light'>{errors.category && touched.category ? errors.category : null}</div>
|
||||
{
|
||||
values.category && <>
|
||||
{
|
||||
{
|
||||
[serverReportCats[1]]: <Message type='info'>
|
||||
<h3 className='font-bold text-xl'>본인 혹은 다른 사람이 위험에 처해 있나요?</h3>
|
||||
<p>당신은 소중한 사람입니다.</p>
|
||||
<p className='list-disc list-item list-inside'>자살예방상담전화 1393 | 청소년전화 1388</p>
|
||||
</Message>,
|
||||
[serverReportCats[3]]: <DMCA values={values} errors={errors} touched={touched} setFieldValue={setFieldValue} />,
|
||||
[serverReportCats[4]]: <Message type='warning'>
|
||||
<h3 className='font-bold text-xl'>디스코드 약관을 위반사항을 신고하시려고요?</h3>
|
||||
<p><a className='text-blue-400' target='_blank' rel='noreferrer' href='http://dis.gd/report'>디스코드 문의</a>를 통해 직접 디스코드에 신고하실 수도 있습니다.</p>
|
||||
</Message>
|
||||
|
||||
}[values.category]
|
||||
}
|
||||
{
|
||||
![serverReportCats[3]].includes(values.category) && <>
|
||||
<h3 className='font-bold mt-2'>설명</h3>
|
||||
<p className='text-gray-400 text-sm mb-1'>최대한 자세하게 기재해주세요.</p>
|
||||
<TextField values={values} errors={errors} touched={touched} setFieldValue={setFieldValue} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
</Formik>
|
||||
}
|
||||
</Container>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: Context) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const data = await get.server.load(ctx.query.id)
|
||||
const user = await get.Authorization(parsed?.token)
|
||||
|
||||
return {
|
||||
props: {
|
||||
csrfToken: getToken(ctx.req, ctx.res),
|
||||
data,
|
||||
user: await get.user.load(user || '')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ReportServerProps {
|
||||
csrfToken: string
|
||||
data: Server
|
||||
user: User
|
||||
}
|
||||
|
||||
interface Context extends CsrfContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default ReportServer
|
||||
135
pages/servers/[id]/vote.tsx
Normal file
135
pages/servers/[id]/vote.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { NextPage } from 'next'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { CsrfContext, ResponseProps, Server, Theme, User } from '@types'
|
||||
import { get } from '@utils/Query'
|
||||
import { parseCookie, checkServerFlag, makeServerURL } from '@utils/Tools'
|
||||
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import NotFound from 'pages/404'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Captcha from '@components/Captcha'
|
||||
import { useState } from 'react'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import Day from '@utils/Day'
|
||||
import { getJosaPicker } from 'josa'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
const Tag = dynamic(() => import('@components/Tag'))
|
||||
const Segment = dynamic(() => import('@components/Segment'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const Login = dynamic(() => import('@components/Login'))
|
||||
const Message = dynamic(() => import('@components/Message'))
|
||||
|
||||
const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken }) => {
|
||||
const [ votingStatus, setVotingStatus ] = useState(0)
|
||||
const [ result, setResult ] = useState<ResponseProps<{retryAfter?: number}>>(null)
|
||||
const router = useRouter()
|
||||
if(!data?.id) return <NotFound />
|
||||
if(!user) return <Login>
|
||||
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
|
||||
images: [
|
||||
{
|
||||
url: KoreanbotsEndPoints.CDN.icon(data.id, { format: 'png', size: 256 }),
|
||||
width: 256,
|
||||
height: 256,
|
||||
alt: 'Server Icon'
|
||||
}
|
||||
]
|
||||
}} />
|
||||
</Login>
|
||||
|
||||
if((checkServerFlag(data.flags, 'trusted') || checkServerFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== router.query.id) router.push(`/servers/${data.vanity}/vote?csrfToken=${csrfToken}`)
|
||||
return <Container paddingTop className='py-10'>
|
||||
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
|
||||
images: [
|
||||
{
|
||||
url: KoreanbotsEndPoints.CDN.icon(data.id, { format: 'png', size: 256 }),
|
||||
width: 256,
|
||||
height: 256,
|
||||
alt: 'Server Avatar'
|
||||
}
|
||||
]
|
||||
}} />
|
||||
{
|
||||
data.state === 'blocked' ? <div className='pb-40'>
|
||||
<Message type='error'>
|
||||
<h2 className='text-lg font-black'>해당 서버는 관리자에 의해 삭제되었습니다.</h2>
|
||||
</Message>
|
||||
</div> : <>
|
||||
<Advertisement />
|
||||
<Link href={makeServerURL(data)}>
|
||||
<a className='text-blue-500 hover:opacity-80'><i className='fas fa-arrow-left mt-3 mb-3' /> <strong>{data.name}</strong>{getJosaPicker('로')(data.name)} 돌아가기</a>
|
||||
</Link>
|
||||
<Segment className='mb-16 py-8'>
|
||||
<div className='text-center'>
|
||||
<ServerIcon id={data.id} className='mx-auto w-52 h-52 bg-white mb-4' />
|
||||
<Tag text={<span><i className='fas fa-heart text-red-600' /> {data.votes}</span>} dark />
|
||||
<h1 className='text-3xl font-bold mt-3'>{data.name}</h1>
|
||||
<h4 className='text-md mt-1'>12시간마다 다시 투표하실 수 있습니다.</h4>
|
||||
<div className='inline-block mt-2'>
|
||||
{
|
||||
votingStatus === 0 ? <Button onClick={()=> setVotingStatus(1)}>
|
||||
<><i className='far fa-heart text-red-600'/> 하트 추가</>
|
||||
</Button>
|
||||
: votingStatus === 1 ? <Captcha dark={theme === 'dark'} onVerify={async (key) => {
|
||||
const res = await Fetch<{ retryAfter: number }|unknown>(`/servers/${data.id}/vote`, { method: 'POST', body: JSON.stringify({ _csrf: csrfToken, _captcha: key }) })
|
||||
setResult(res)
|
||||
setVotingStatus(2)
|
||||
}}
|
||||
/>
|
||||
: result.code === 200 ? <h2 className='text-2xl font-bold'>해당 서버에 투표했습니다!</h2>
|
||||
: result.code === 429 ? <>
|
||||
<h2 className='text-2xl font-bold'>이미 해당 서버에 투표하였습니다.</h2>
|
||||
<h4 className='text-md mt-1'>{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수 있습니다.</h4>
|
||||
</>
|
||||
: <p>{result.message}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Segment>
|
||||
<Advertisement /></>
|
||||
}
|
||||
</Container>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: Context) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const data = await get.server.load(ctx.query.id)
|
||||
const user = await get.Authorization(parsed?.token)
|
||||
|
||||
return {
|
||||
props: {
|
||||
csrfToken: getToken(ctx.req, ctx.res),
|
||||
data,
|
||||
user: await get.user.load(user || '')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface VoteServerProps {
|
||||
csrfToken: string
|
||||
vote: boolean
|
||||
data: Server
|
||||
user: User
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
interface Context extends CsrfContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default VoteServer
|
||||
62
pages/servers/categories/[category].tsx
Normal file
62
pages/servers/categories/[category].tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { Server, List } from '@types'
|
||||
import { serverCategoryListArgumentSchema } from '@utils/Yup'
|
||||
import NotFound from 'pages/404'
|
||||
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
|
||||
const Category: NextPage<CategoryProps> = ({ data, query }) => {
|
||||
if(!data || data.data.length === 0 || data.totalPage < Number(query.page)) return <NotFound message={data?.data.length === 0 ? '해당 카테고리에 해당되는 서버가 존재하지 않습니다.' : null} />
|
||||
return <>
|
||||
<Hero type='servers' header={`${query.category} 카테고리 서버들`} description={`다양한 "${query.category}" 카테고리의 서버들을 만나보세요.`} />
|
||||
<Container>
|
||||
<Advertisement />
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
data.data.map(server => <ServerCard type='list' key={server.id} server={server} /> )
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname={`/servers/categories/${query.category}`} />
|
||||
<Advertisement />
|
||||
</Container>
|
||||
</>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: Context) => {
|
||||
let data: List<Server>
|
||||
if(!ctx.query.page) ctx.query.page = '1'
|
||||
const validate = await serverCategoryListArgumentSchema.validate(ctx.query).then(el => el).catch(() => null)
|
||||
if(!validate || isNaN(Number(ctx.query.page))) data = null
|
||||
else data = await get.serverList.category.load(JSON.stringify({ page: Number(ctx.query.page), category: ctx.query.category }))
|
||||
return {
|
||||
props: {
|
||||
data,
|
||||
query: ctx.query
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CategoryProps {
|
||||
data: List<Server>
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface Context extends NextPageContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
category: string
|
||||
page?: string
|
||||
}
|
||||
|
||||
export default Category
|
||||
30
pages/servers/categories/index.tsx
Normal file
30
pages/servers/categories/index.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextPage } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
import { serverCategories, serverCategoryIcon } from '@utils/Constants'
|
||||
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const Tag = dynamic(() => import('@components/Tag'))
|
||||
const Segment = dynamic(() => import('@components/Segment'))
|
||||
|
||||
const Categories:NextPage = () => {
|
||||
return <Container paddingTop>
|
||||
<NextSeo title='전체 카테고리' description='한국 디스코드 리스트 서버들의 전체 카테고리입니다.' />
|
||||
<h1 className='text-2xl font-bold mt-2 mb-5'>전체 카테고리</h1>
|
||||
<Segment className='mb-10'>
|
||||
<div className='text-center flex flex-wrap mt-1.5'>
|
||||
{
|
||||
serverCategories.map(t => <Tag key={t} text={<>
|
||||
<i className={serverCategoryIcon[t]} /> {t}
|
||||
</>} href={`/servers/categories/${t}`} dark bigger /> )
|
||||
}
|
||||
</div>
|
||||
</Segment>
|
||||
<Advertisement />
|
||||
</Container>
|
||||
}
|
||||
|
||||
export default Categories
|
||||
58
pages/servers/index.tsx
Normal file
58
pages/servers/index.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { NextPage } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { Server, List } from '@types'
|
||||
import * as Query from '@utils/Query'
|
||||
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
|
||||
const ServerIndex: NextPage<ServerIndexProps> = ({ votes, trusted }) => {
|
||||
return <>
|
||||
<Hero type='servers' />
|
||||
<Container className='pb-10'>
|
||||
<Advertisement />
|
||||
<h1 className='text-3xl font-bold mt-10 mb-2'>
|
||||
<i className='far fa-heart mr-3 text-pink-600' /> 하트 랭킹
|
||||
</h1>
|
||||
<p className='text-base'>하트를 많이 받은 서버들의 순위입니다!</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
votes.data.map(server=> <ServerCard type='list' key={server.id} server={server} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={votes.totalPage} currentPage={votes.currentPage} pathname='/servers/list/votes' />
|
||||
<Advertisement />
|
||||
<h1 className='text-3xl font-bold mb-2'>
|
||||
<i className='fa fa-check mr-3 mt-10 text-green-500' /> 신뢰된 서버
|
||||
</h1>
|
||||
<p className='text-base'>한국 디스코드 리스트에서 인증받은 신뢰할 수 있는 서버들입니다!!</p>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
trusted.data.slice(0, 4).map(server=> <ServerCard type='list' key={server.id} server={server} />)
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Advertisement />
|
||||
</Container>
|
||||
</>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async() => {
|
||||
const votes = await Query.get.serverList.votes.load(1)
|
||||
const trusted = await Query.get.serverList.trusted.load(1)
|
||||
|
||||
return { props: { votes,trusted }}
|
||||
|
||||
}
|
||||
|
||||
interface ServerIndexProps {
|
||||
votes: List<Server>
|
||||
newBots: List<Server>
|
||||
trusted: List<Server>
|
||||
}
|
||||
|
||||
export default ServerIndex
|
||||
63
pages/servers/list/votes.tsx
Normal file
63
pages/servers/list/votes.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import { useRouter } from 'next/router'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { Server, List } from '@types'
|
||||
import { get }from '@utils/Query'
|
||||
|
||||
import NotFound from '../../404'
|
||||
import { PageCount } from '@utils/Yup'
|
||||
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
|
||||
const Votes:NextPage<VotesProps> = ({ data }) => {
|
||||
const router = useRouter()
|
||||
if(!data || data.data.length === 0 || data.totalPage < Number(router.query.page)) return <NotFound />
|
||||
return <>
|
||||
<Hero type='servers' header='하트 랭킹' description='하트를 많이 받은 서버들의 순위입니다!'/>
|
||||
<section id='list'>
|
||||
<Container className='pb-10'>
|
||||
<Advertisement />
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
data.data.map(server => <ServerCard type='list' key={server.id} server={server} /> )
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/servers/list/votes' />
|
||||
<Advertisement />
|
||||
</Container>
|
||||
</section>
|
||||
</>
|
||||
}
|
||||
export const getServerSideProps = async (ctx:Context) => {
|
||||
let data: List<Server>
|
||||
if(!ctx.query.page) ctx.query.page = '1'
|
||||
const validate = await PageCount.validate(ctx.query.page).then(el => el).catch(() => null)
|
||||
if(!validate || isNaN(Number(ctx.query.page))) data = null
|
||||
else data = await get.serverList.votes.load(Number(ctx.query.page))
|
||||
return {
|
||||
props: {
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface VotesProps {
|
||||
data: List<Server>
|
||||
}
|
||||
|
||||
interface Context extends NextPageContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
page: string
|
||||
}
|
||||
|
||||
export default Votes
|
||||
88
pages/servers/search.tsx
Normal file
88
pages/servers/search.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import type { FC } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { List, Server } from '@types'
|
||||
import { get } from '@utils/Query'
|
||||
import { SearchQuerySchema } from '@utils/Yup'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
|
||||
|
||||
const Hero = dynamic(() => import('@components/Hero'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Paginator = dynamic(() => import('@components/Paginator'))
|
||||
const LongButton = dynamic(() => import('@components/LongButton'))
|
||||
const Redirect = dynamic(() => import('@components/Redirect'))
|
||||
|
||||
const SearchComponent: FC<{data: List<Server>, query: URLQuery }> = ({ data, query }) => {
|
||||
return <div className='py-10'>
|
||||
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'>검색 결과가 없습니다.</h1> :
|
||||
<>
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
data.data.map(el => <ServerCard key={el.id} type='list' server={el as Server} /> )
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/search' searchParams={query} />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
const Search:NextPage<SearchProps> = ({ serverData, query }) => {
|
||||
if(!query?.q) return <Redirect text={false} to='/' />
|
||||
return <>
|
||||
<Hero type='servers' header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
|
||||
<Container>
|
||||
<section id='list'>
|
||||
<Advertisement />
|
||||
<h1 className='text-4xl font-bold'>서버</h1>
|
||||
<SearchComponent data={serverData} query={query} />
|
||||
<h1 className='text-2xl font-bold py-10'>봇을 찾으시나요?</h1>
|
||||
<LongButton center href={KoreanbotsEndPoints.URL.searchBot(query.q)}>봇 검색 결과 보기</LongButton>
|
||||
<Advertisement />
|
||||
</section>
|
||||
</Container>
|
||||
</>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async(ctx: Context) => {
|
||||
if(ctx.query.query && !ctx.query.q) ctx.query.q = ctx.query.query
|
||||
if(!ctx.query?.q) {
|
||||
ctx.res.statusCode = 301
|
||||
ctx.res.setHeader('Location', '/')
|
||||
return { props: {} }
|
||||
}
|
||||
if(!ctx.query.page) ctx.query.page = '1'
|
||||
const validate = await SearchQuerySchema.validate(ctx.query).then(el => el).catch(() => null)
|
||||
if(!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } }
|
||||
else {
|
||||
return {
|
||||
props: {
|
||||
serverData: await get.serverList.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
|
||||
query: ctx.query
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface SearchProps {
|
||||
serverData?: List<Server>
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface Context extends NextPageContext {
|
||||
query: URLQuery
|
||||
}
|
||||
|
||||
interface URLQuery extends ParsedUrlQuery {
|
||||
q?: string
|
||||
query?: string
|
||||
page?: string
|
||||
}
|
||||
|
||||
export default Search
|
||||
@ -12,7 +12,7 @@ const ToS: NextPage<ToSProps> = ({ content }) => {
|
||||
return (
|
||||
<Docs
|
||||
header='서비스 이용약관'
|
||||
description='한국 디스코드봇 리스트의 서비스를 이용하실 때 지켜야하는 약관입니다.'
|
||||
description='한국 디스코드 리스트의 서비스를 이용하실 때 지켜야하는 약관입니다.'
|
||||
>
|
||||
<Markdown text={content} />
|
||||
</Docs>
|
||||
|
||||
@ -6,7 +6,7 @@ import { SnowflakeUtil } from 'discord.js'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
import { josa } from 'josa'
|
||||
|
||||
import { Bot, User, Theme } from '@types'
|
||||
import { Bot, User, Theme, Server } from '@types'
|
||||
import { get } from '@utils/Query'
|
||||
import { checkUserFlag, parseCookie } from '@utils/Tools'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
@ -20,6 +20,7 @@ const Container = dynamic(() => import('@components/Container'))
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
const BotCard = dynamic(() => import('@components/BotCard'))
|
||||
const ServerCard = dynamic(() => import('@components/ServerCard'))
|
||||
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
|
||||
const Tag = dynamic(() => import('@components/Tag'))
|
||||
const Advertisement = dynamic(() => import('@components/Advertisement'))
|
||||
@ -63,7 +64,7 @@ const Users: NextPage<UserProps> = ({ user, data }) => {
|
||||
</div>
|
||||
<div className='badges flex mb-2 justify-center lg:justify-start'>
|
||||
{checkUserFlag(data.flags, 'staff') && (
|
||||
<Tooltip text='한국 디스코드봇 리스트 스탭입니다.' direction='left'>
|
||||
<Tooltip text='한국 디스코드 리스트 스탭입니다.' direction='left'>
|
||||
<div className='pr-5 text-koreanbots-blue text-2xl'>
|
||||
<i className='fas fa-hammer' />
|
||||
</div>
|
||||
@ -102,7 +103,7 @@ const Users: NextPage<UserProps> = ({ user, data }) => {
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<h2 className='mt-8 text-3xl font-bold'>제작한 봇</h2>
|
||||
<h2 className='mt-8 pb-4 text-3xl font-bold'>소유한 봇</h2>
|
||||
|
||||
{data.bots.length === 0 ? <h2 className='text-xl'>소유한 봇이 없습니다.</h2> :
|
||||
<ResponsiveGrid>
|
||||
@ -114,6 +115,16 @@ const Users: NextPage<UserProps> = ({ user, data }) => {
|
||||
</ResponsiveGrid>
|
||||
}
|
||||
|
||||
<h2 className='py-4 text-3xl font-bold'>소유한 서버</h2>
|
||||
{data.servers.length === 0 ? <h2 className='text-xl'>소유한 서버가 없습니다.</h2> :
|
||||
<ResponsiveGrid>
|
||||
{
|
||||
(data.servers as Server[]).map((server: Server) => (
|
||||
<ServerCard type='list' key={server.id} server={server} />
|
||||
))
|
||||
}
|
||||
</ResponsiveGrid>
|
||||
}
|
||||
<Advertisement />
|
||||
</Container>
|
||||
)
|
||||
|
||||
@ -9,13 +9,13 @@ const Verification: NextPage = () => {
|
||||
return (
|
||||
<Docs
|
||||
header='인증'
|
||||
description='한국 디스코드봇 리스트의 신뢰된 봇은 디스코드 인증보다 더 자세한 기준으로 신뢰성을 주기 위한 제도입니다.'
|
||||
description='한국 디스코드 리스트의 신뢰된 봇은 디스코드 인증보다 더 자세한 기준으로 신뢰성을 주기 위한 제도입니다.'
|
||||
>
|
||||
<h1 className='mb-3 text-4xl font-bold'>
|
||||
<span className='text-koreanbots-blue mr-5'>
|
||||
<i className='fas fa-award' />
|
||||
</span>신뢰된 봇</h1>
|
||||
<p>한국 디스코드봇 리스트의 신뢰된 봇은 디스코드 인증보다 더 자세한 기준으로 신뢰성을 주기 위한 제도입니다.</p>
|
||||
<p>한국 디스코드 리스트의 신뢰된 봇은 디스코드 인증보다 더 자세한 기준으로 신뢰성을 주기 위한 제도입니다.</p>
|
||||
<h2 className='mt-10 text-3xl font-semibold' id='기준'>
|
||||
기준
|
||||
</h2>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "한국 디스코드봇 리스트",
|
||||
"name": "한국 디스코드 리스트",
|
||||
"short_name": "한디리",
|
||||
"lang": "ko-KR",
|
||||
"description": "다양한 국내 디스코드봇들을 확인하고, 초대해보세요!",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">
|
||||
<ShortName>한국 디스코드봇 리스트</ShortName>
|
||||
<ShortName>한국 디스코드 리스트</ShortName>
|
||||
<Description>국내 디스코드봇을 한 곳에서.</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Image width="16" height="16" type="image/x-icon">https://koreanbots.dev/logo.png</Image>
|
||||
|
||||
117
types/index.ts
117
types/index.ts
@ -1,5 +1,8 @@
|
||||
import { IncomingMessage } from 'http'
|
||||
import { NextPageContext } from 'next'
|
||||
import type { GuildFeatures } from 'discord.js'
|
||||
import type { IncomingMessage } from 'http'
|
||||
import type { NextPageContext } from 'next'
|
||||
|
||||
export type Nullable<T> = T | null
|
||||
|
||||
export interface Bot {
|
||||
id: string
|
||||
@ -16,7 +19,7 @@ export interface Bot {
|
||||
shards: number
|
||||
intro: string
|
||||
desc: string
|
||||
category: Category[]
|
||||
category: BotCategory[]
|
||||
web: string | null
|
||||
git: string | null
|
||||
url: string | null
|
||||
@ -27,6 +30,41 @@ export interface Bot {
|
||||
owners: User[] | string[]
|
||||
}
|
||||
|
||||
export interface RawGuild {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
owner: boolean
|
||||
permissions: number
|
||||
features: GuildFeatures[]
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: string
|
||||
name: string
|
||||
flags: number
|
||||
state: ServerState
|
||||
icon: string | null
|
||||
votes: number
|
||||
members: number | null
|
||||
boostTier: number | null
|
||||
emojis: Emoji[]
|
||||
intro: string
|
||||
desc: string
|
||||
category: ServerCategory[]
|
||||
invite: string
|
||||
vanity: string | null
|
||||
bg: string | null
|
||||
banner: string | null
|
||||
owner: User | null
|
||||
bots: Bot[] | string[]
|
||||
}
|
||||
|
||||
export interface Emoji {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
export interface User {
|
||||
id: string
|
||||
avatar: string
|
||||
@ -35,6 +73,7 @@ export interface User {
|
||||
flags: number
|
||||
github: string
|
||||
bots: Bot[] | string[]
|
||||
servers: Server[] | string[]
|
||||
}
|
||||
|
||||
export interface BotSpec {
|
||||
@ -43,6 +82,41 @@ export interface BotSpec {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface ServerMongoData {
|
||||
data: ServerData
|
||||
}
|
||||
|
||||
export interface ServerData {
|
||||
name: string
|
||||
updatedAt: string
|
||||
icon: string
|
||||
owner: string
|
||||
admins: string[]
|
||||
bots: string[]
|
||||
memberCount: number
|
||||
boostTier: number
|
||||
features: GuildFeatures[]
|
||||
emojis: ServerEmojiData[]
|
||||
}
|
||||
|
||||
export interface ServerEmojiData {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
export interface BotMongoData {
|
||||
serverMetrix: MetrixData[]
|
||||
voteMetrix: MetrixData[]
|
||||
inviteMetrix: MetrixData[]
|
||||
viewMetrix: MetrixData[]
|
||||
}
|
||||
|
||||
export interface MetrixData {
|
||||
_id: string
|
||||
count: number
|
||||
day: string
|
||||
createdAt: Date
|
||||
}
|
||||
export interface DocsData {
|
||||
name: string
|
||||
list?: DocsData[]
|
||||
@ -60,7 +134,7 @@ export enum UserFlags {
|
||||
export enum BotFlags {
|
||||
general = 0 << 0,
|
||||
official = 1 << 0,
|
||||
holding = 1 << 1,
|
||||
reserved = 1 << 1,
|
||||
trusted = 1 << 2,
|
||||
partnered = 1 << 3,
|
||||
verified = 1 << 4,
|
||||
@ -68,6 +142,16 @@ export enum BotFlags {
|
||||
hackerthon = 1 << 6,
|
||||
}
|
||||
|
||||
export enum ServerFlags {
|
||||
general = 0 << 0,
|
||||
official = 1 << 0,
|
||||
reserved = 1 << 1,
|
||||
trusted = 1 << 2,
|
||||
partnered = 1 << 3,
|
||||
verified = 1 << 4,
|
||||
discord_partnered = 1 << 5,
|
||||
}
|
||||
|
||||
export enum DiscordUserFlags {
|
||||
DISCORD_EMPLOYEE = 1 << 0,
|
||||
DISCORD_PARTNER = 1 << 1,
|
||||
@ -84,9 +168,9 @@ export enum DiscordUserFlags {
|
||||
VERIFIED_DEVELOPER = 1 << 17,
|
||||
}
|
||||
|
||||
export interface BotList {
|
||||
export interface List<T> {
|
||||
type: ListType
|
||||
data: Bot[]
|
||||
data: T[]
|
||||
currentPage: number
|
||||
totalPage: number
|
||||
}
|
||||
@ -99,7 +183,7 @@ export interface SubmittedBot {
|
||||
prefix: string
|
||||
intro: string
|
||||
desc: string
|
||||
category: Category[]
|
||||
category: BotCategory[]
|
||||
web: string | null
|
||||
git: string | null
|
||||
url: string | null
|
||||
@ -161,7 +245,7 @@ export type Theme = 'dark' | 'light' | 'system'
|
||||
export type Status = 'online' | 'offline' | 'dnd' | 'idle' | 'streaming' | null
|
||||
|
||||
export type BotState = 'ok' | 'reported' | 'blocked' | 'archived' | 'private'
|
||||
|
||||
export type ServerState = 'ok' | 'reported' | 'blocked' | 'unreachable'
|
||||
export type Library =
|
||||
| 'discord.js'
|
||||
| 'Eris'
|
||||
@ -187,7 +271,7 @@ export type Library =
|
||||
| '기타'
|
||||
| '비공개'
|
||||
|
||||
export type Category =
|
||||
export type BotCategory =
|
||||
| '관리'
|
||||
| '뮤직'
|
||||
| '전적'
|
||||
@ -203,6 +287,19 @@ export type Category =
|
||||
| 'NSFW'
|
||||
| '검색'
|
||||
|
||||
export type ServerCategory =
|
||||
| '커뮤니티'
|
||||
| '친목'
|
||||
| '음악'
|
||||
| '기술'
|
||||
| '교육'
|
||||
// 게임
|
||||
| '게임'
|
||||
| '오버워치'
|
||||
| '리그 오브 레전드'
|
||||
| '배틀그라운드'
|
||||
| '마인크래프트'
|
||||
|
||||
export type ReportCategory =
|
||||
| '위법'
|
||||
| '봇을 이용한 테러'
|
||||
@ -210,7 +307,7 @@ export type ReportCategory =
|
||||
| '스팸, 도배, 의미없는 텍스트'
|
||||
| '폭력, 자해, 테러 옹호하거나 조장하는 컨텐츠'
|
||||
| '오픈소스 라이선스, 저작권 위반 등 권리 침해'
|
||||
| 'Discord ToS를 위반하거나 한국 디스코드봇 리스트 가이드라인 위반'
|
||||
| 'Discord ToS를 위반하거나 한국 디스코드 리스트 가이드라인 위반'
|
||||
| '기타'
|
||||
|
||||
export type ListType = 'VOTE' | 'TRUSTED' | 'NEW' | 'PARTNERED' | 'CATEGORY' | 'SEARCH'
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Bot, ImageOptions, KoreanbotsImageOptions } from '@types'
|
||||
import { Bot, ImageOptions, KoreanbotsImageOptions, Server } from '@types'
|
||||
import { KeyMap } from 'react-hotkeys'
|
||||
import { formatNumber, makeImageURL } from './Tools'
|
||||
|
||||
// Website META DATA
|
||||
export const TITLE = '한국 디스코드봇 리스트'
|
||||
export const TITLE = '한국 디스코드 리스트'
|
||||
export const DESCRIPTION = '다양한 국내 디스코드 봇들을 확인하고, 초대해보세요!'
|
||||
export const THEME_COLOR = '#3366FF'
|
||||
export const DSKR_BOT_ID = '784618064167698472'
|
||||
|
||||
export const VOTE_COOLDOWN = 12 * 60 * 60 * 1000
|
||||
export const BUG_REPORTERS = ['260303569591205888']
|
||||
@ -67,7 +68,7 @@ export const library = [
|
||||
'비공개',
|
||||
]
|
||||
|
||||
export const categories = [
|
||||
export const botCategories = [
|
||||
// 상위 카테고리
|
||||
'관리',
|
||||
'뮤직',
|
||||
@ -95,7 +96,7 @@ export const categories = [
|
||||
'마인크래프트'
|
||||
]
|
||||
|
||||
export const categoryIcon = {
|
||||
export const botCategoryIcon = {
|
||||
'관리': 'fas fa-cogs',
|
||||
'뮤직': 'fas fa-music',
|
||||
'전적': 'fas fa-puzzle-piece',
|
||||
@ -119,6 +120,37 @@ export const categoryIcon = {
|
||||
'마인크래프트': 'fas fa-cubes'
|
||||
}
|
||||
|
||||
export const serverCategories = [
|
||||
'커뮤니티',
|
||||
'IT & 과학',
|
||||
'봇',
|
||||
'친목',
|
||||
'음악',
|
||||
'교육',
|
||||
'연애',
|
||||
// 게임
|
||||
'게임',
|
||||
'오버워치',
|
||||
'리그 오브 레전드',
|
||||
'배틀그라운드',
|
||||
'마인크래프트'
|
||||
]
|
||||
|
||||
export const serverCategoryIcon = {
|
||||
'커뮤니티': 'fas fa-comments',
|
||||
'친목': 'fas fa-user-friends',
|
||||
'음악': 'fas fa-music',
|
||||
'IT & 과학': 'fas fa-flask',
|
||||
'봇': 'fas fa-robot',
|
||||
'교육': 'fas fa-graduation-cap',
|
||||
'연애': 'fas fa-hand-holding-heart',
|
||||
'게임': 'fas fa-gamepad',
|
||||
'오버워치': 'fas fa-mask',
|
||||
'리그 오브 레전드': 'fas fa-chess',
|
||||
'배틀그라운드': 'fas fa-meteor',
|
||||
'마인크래프트': 'fas fa-cubes'
|
||||
}
|
||||
|
||||
export const reportCats = [
|
||||
'위법',
|
||||
'봇을 이용한 테러',
|
||||
@ -126,7 +158,16 @@ export const reportCats = [
|
||||
'스팸, 도배, 의미없는 텍스트',
|
||||
'폭력, 자해, 테러 옹호하거나 조장하는 컨텐츠',
|
||||
'오픈소스 라이선스, 저작권 위반 등 권리 침해',
|
||||
'Discord ToS를 위반하거나 한국 디스코드봇 리스트 가이드라인 위반',
|
||||
'Discord ToS 또는 한국 디스코드 리스트 가이드라인 위반',
|
||||
'기타',
|
||||
]
|
||||
|
||||
export const serverReportCats = [
|
||||
'위법',
|
||||
'괴롭힘, 모욕, 명예훼손',
|
||||
'폭력, 자해, 테러 옹호하거나 조장하는 컨텐츠',
|
||||
'저작권 위반 등 권리 침해',
|
||||
'Discord ToS 또는 한국 디스코드 리스트 가이드라인 위반',
|
||||
'기타',
|
||||
]
|
||||
|
||||
@ -145,6 +186,7 @@ export const MessageColor = {
|
||||
|
||||
export const BASE_URLs = {
|
||||
api: 'https://discord.com/api',
|
||||
invite: 'https://discord.gg',
|
||||
cdn: 'https://cdn.discordapp.com',
|
||||
camo: 'https://camo.koreanbots.dev'
|
||||
}
|
||||
@ -174,10 +216,33 @@ export const BotBadgeType = (data: Bot) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const ServerBadgeType = (data: Server) => {
|
||||
return {
|
||||
members: {
|
||||
label: '멤버수',
|
||||
status: !data.members ? 'N/A' : formatNumber(data.members),
|
||||
color: '7289DA'
|
||||
},
|
||||
votes: {
|
||||
label: '하트',
|
||||
status: `${formatNumber(data.votes)}`,
|
||||
color: 'ef4444'
|
||||
},
|
||||
boost: {
|
||||
label: '부스트',
|
||||
status: `${!data.boostTier ? 0 : data.boostTier}레벨`,
|
||||
color: 'fe73fa'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const DiscordEnpoints = {
|
||||
Token: BASE_URLs.api + '/oauth2/token',
|
||||
Me: BASE_URLs.api + '/v8/users/@me',
|
||||
InviteApplication: (id: string, perms: { [perm: string]: boolean }, scope: string, redirect?: string): string => `${BASE_URLs.api}/oauth2/authorize?client_id=${id ? id.split(' ')[0] : 'CLIENT_ID'}&permissions=${Object.keys(perms).filter(el => perms[el]).map(el => Number(el)).reduce((prev, curr) => prev | curr, 0)}&scope=${scope ? encodeURI(scope) : 'bot'}${redirect ? `&redirect_uri=${encodeURIComponent(redirect)}` : ''}`,
|
||||
Me: BASE_URLs.api + '/v9/users/@me',
|
||||
Guilds: BASE_URLs.api + '/v9/users/@me/guilds',
|
||||
InviteApplication: (id: string, perms: { [perm: string]: boolean }, scope: string, redirect?: string, guild_id?: string): string => `${BASE_URLs.api}/oauth2/authorize?client_id=${id ? id.split(' ')[0] : 'CLIENT_ID'}&permissions=${Object.keys(perms).filter(el => perms[el]).map(el => Number(el)).reduce((prev, curr) => prev | curr, 0)}&scope=${scope ? encodeURI(scope) : 'bot'}${redirect ? `&redirect_uri=${encodeURIComponent(redirect)}` : ''}${guild_id ? `&guild_id=${guild_id}` : ''}`,
|
||||
ServerInvite: (code: string): string => `${BASE_URLs.invite}/${code}`,
|
||||
CDN: class CDN {
|
||||
static root = BASE_URLs.cdn
|
||||
static emoji (id: string, options:ImageOptions={}) { return makeImageURL(`${this.root}/emojis/${id}`, options) }
|
||||
@ -191,25 +256,36 @@ export const KoreanbotsEndPoints = {
|
||||
OG: class {
|
||||
static root = 'https://og.kbots.link'
|
||||
static origin = 'https://koreanbots.dev'
|
||||
static bot(id: string, name: string, bio: string, tags: string[], stats: string[]) {
|
||||
static generate(id: string, name: string, bio: string, tags: string[], stats: string[], type: 'bot' | 'server') {
|
||||
const u = new URL(this.root)
|
||||
u.pathname = name
|
||||
u.searchParams.append('image', this.origin + KoreanbotsEndPoints.CDN.avatar(id, { format: 'webp', size: 256 }))
|
||||
u.pathname = name + '.png'
|
||||
u.searchParams.append('image', this.origin + ( type === 'bot' ? KoreanbotsEndPoints.CDN.avatar(id, { format: 'webp', size: 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'webp', size: 256 }) ))
|
||||
u.searchParams.append('bio', bio)
|
||||
u.searchParams.append('type', type)
|
||||
tags.map(t => u.searchParams.append('tags', t))
|
||||
stats.map(s => u.searchParams.append('stats', s))
|
||||
return u.href
|
||||
}
|
||||
static bot(id: string, name: string, bio: string, tags: string[], stats: string[]) {
|
||||
return this.generate(id, name, bio, tags, stats, 'bot')
|
||||
}
|
||||
static server(id: string, name: string, bio: string, tags: string[], stats: string[]) {
|
||||
return this.generate(id, name, bio, tags, stats, 'server')
|
||||
}
|
||||
},
|
||||
CDN: class {
|
||||
static root = '/api/image'
|
||||
static avatar (id: string, options: KoreanbotsImageOptions) { return makeImageURL(`${this.root}/discord/avatars/${id}`, options) }
|
||||
static icon (id: string, options: KoreanbotsImageOptions) { return makeImageURL(`${this.root}/discord/icons/${id}`, options) }
|
||||
},
|
||||
URL: class {
|
||||
static root = process.env.KOREANBOTS_URL || 'https://koreanbots.dev'
|
||||
static bot (id: string) { return `${this.root}/bots/${id}` }
|
||||
static server (id: string) { return `${this.root}/servers/${id}` }
|
||||
static user (id: string) { return `${this.root}/users/${id}` }
|
||||
static submittedBot(id: string, date: number) { return `${this.root}/pendingBots/${id}/${date}` }
|
||||
static searchBot(query: string) { return `${this.root}/bots/search?q=${encodeURIComponent(query)}` }
|
||||
static searchServer(query: string) { return `${this.root}/servers/search?q=${encodeURIComponent(query)}` }
|
||||
},
|
||||
baseAPI: '/api/v2',
|
||||
login: '/api/auth/discord',
|
||||
@ -240,10 +316,10 @@ export const git = { 'github.com': { icon: 'github', text: 'GitHub' }, 'gitlab.
|
||||
export const KoreanbotsDiscord = 'https://discord.gg/JEh53MQ'
|
||||
export const ThemeColors = [{ name: '파랑', rgb: 'rgb(51, 102, 255)', hex: '#3366FF', color: 'koreanbots-blue' }, { name: '하양', rgb: 'rgb(251, 251, 251)', hex: '#FBFBFB', color: 'little-white' }, { name: '검정', rgb: 'rgb(27, 30, 35)', hex: '#1B1E23', color: 'very-black' }, { name: '보라', rgb: 'rgb(114, 137, 218)', hex: '#7289DA', color: 'discord-blurple' } ]
|
||||
export const KoreanbotsEmoji = [{
|
||||
name: '한국 디스코드봇 리스트',
|
||||
name: '한국 디스코드 리스트',
|
||||
short_names: ['koreanbots', 'kbots', 'dbkr'],
|
||||
emoticons: [],
|
||||
keywords: ['koreanbots', '한국 디스코드봇 리스트', '한디리', 'kbots'],
|
||||
keywords: ['koreanbots', '한국 디스코드 리스트', '한디리', 'kbots'],
|
||||
imageUrl: '/logo.png'
|
||||
},
|
||||
{
|
||||
@ -289,6 +365,7 @@ export const ErrorText = {
|
||||
|
||||
export const ErrorMessage = ['지나가던 고양이가 선을 밟았어요..', '무언가 잘못되었어요..!', '이게 아닌데...', '어쩜 이렇게 오류가 또 나는건지?']
|
||||
|
||||
export const ServerIntroList = ['한국어를 배울 수 있는 최고의 공간입니다!', '김치의 다양한 요리법을 소개하는 서버입니다.', '좋아하는 노래를 들을 수 있는 곳 입니다.', '게임을 함께 할 사람을 찾을 수 있습니다.']
|
||||
export const BotSubmissionDenyReasonPresetsName = {
|
||||
MISSING_VERIFY: '개발자 확인 불가',
|
||||
OFFLINE: '봇 오프라인',
|
||||
|
||||
29
utils/Mongo.ts
Normal file
29
utils/Mongo.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import mongoose from 'mongoose'
|
||||
import { getYYMMDD } from './Tools'
|
||||
|
||||
mongoose.connect(`mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASSWORD}@${process.env.MONGO_HOST || 'localhost'}/${process.env.MONGO_DATABASE}`)
|
||||
|
||||
const db = mongoose.connection
|
||||
|
||||
db.on('error', console.error.bind(console, 'connection error:'))
|
||||
db.once('open', () =>
|
||||
console.log('[DB] Mongo Connected')
|
||||
)
|
||||
|
||||
const metrix = { day: { type: String, default: getYYMMDD, unique: true }, createdAt: { type: Date, default: Date.now }, count: Number }
|
||||
const botSchema = new mongoose.Schema({
|
||||
_id: String,
|
||||
serverMetrix: [ metrix ],
|
||||
viewMetrix: [ metrix ],
|
||||
voteMetrix: [ { ...metrix, increasement: { type: Number, default: 1 } } ],
|
||||
inviteMetrix: [ metrix ],
|
||||
comments: [ { author: String, date: { type: Date, defualt: Date.now }, comment: String, rating: Number, upvotes: [ String ], downvotes: [ String ] } ],
|
||||
})
|
||||
|
||||
const serverSchema = new mongoose.Schema({
|
||||
_id: String,
|
||||
data: {}
|
||||
})
|
||||
|
||||
export const Bots = mongoose.models.bots || mongoose.model('bots', botSchema)
|
||||
export const Servers = mongoose.models.servers || mongoose.model('servers', serverSchema)
|
||||
402
utils/Query.ts
402
utils/Query.ts
@ -3,19 +3,20 @@ import { TLRU } from 'tlru'
|
||||
import DataLoader from 'dataloader'
|
||||
import { User as DiscordUser } from 'discord.js'
|
||||
|
||||
import { Bot, User, ListType, BotList, TokenRegister, BotFlags, DiscordUserFlags, SubmittedBot } from '@types'
|
||||
import { categories, imageSafeHost, SpecialEndPoints, VOTE_COOLDOWN } from './Constants'
|
||||
import { Bot, Server, User, ListType, List, TokenRegister, BotFlags, DiscordUserFlags, SubmittedBot, DiscordTokenInfo, ServerData, ServerFlags, RawGuild, Nullable } from '@types'
|
||||
import { botCategories, DiscordEnpoints, imageSafeHost, serverCategories, SpecialEndPoints, VOTE_COOLDOWN } from './Constants'
|
||||
|
||||
import knex from './Knex'
|
||||
import { Bots, Servers } from './Mongo'
|
||||
import { DiscordBot, getMainGuild } from './DiscordBot'
|
||||
import { sign, verify } from './Jwt'
|
||||
import { camoUrl, formData, serialize } from './Tools'
|
||||
import { AddBotSubmit, ManageBot } from './Yup'
|
||||
import { camoUrl, formData, getYYMMDD, serialize } from './Tools'
|
||||
import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup'
|
||||
import { markdownImage } from './Regex'
|
||||
|
||||
export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 })
|
||||
|
||||
async function getBot(id: string, owners=true):Promise<Bot> {
|
||||
async function getBot(id: string, topLevel=true):Promise<Bot> {
|
||||
const res = await knex('bots')
|
||||
.select([
|
||||
'id',
|
||||
@ -57,7 +58,7 @@ async function getBot(id: string, owners=true):Promise<Bot> {
|
||||
res[0].status = discordBot.presence?.activities?.find(r => r.type === 'STREAMING') ? 'streaming' : discordBot.presence?.status || null
|
||||
delete res[0].trusted
|
||||
delete res[0].partnered
|
||||
if (owners) {
|
||||
if (topLevel) {
|
||||
res[0].owners = await Promise.all(
|
||||
res[0].owners.map(async (u: string) => await get._rawUser.load(u))
|
||||
)
|
||||
@ -71,27 +72,103 @@ async function getBot(id: string, owners=true):Promise<Bot> {
|
||||
return res[0] ?? null
|
||||
}
|
||||
|
||||
async function getUser(id: string, bots = true):Promise<User> {
|
||||
async function getServer(id: string, topLevel=true): Promise<Server> {
|
||||
const res = await knex('servers')
|
||||
.select([
|
||||
'id',
|
||||
'name',
|
||||
'flags',
|
||||
'intro',
|
||||
'desc',
|
||||
'votes',
|
||||
'owners',
|
||||
'category',
|
||||
'invite',
|
||||
'state',
|
||||
'vanity',
|
||||
'bg',
|
||||
'banner',
|
||||
'flags'
|
||||
])
|
||||
.where({ id })
|
||||
.orWhereRaw(`(flags & ${ServerFlags.trusted}) and vanity=?`, [id])
|
||||
.orWhereRaw(`(flags & ${ServerFlags.partnered}) and vanity=?`, [id])
|
||||
if (res[0]) {
|
||||
const data = await getServerData(res[0].id)
|
||||
if(!data || (+new Date() - +new Date(data.updatedAt)) > 3 * 60 * 1000) res[0].state = 'unreachable'
|
||||
else {
|
||||
res[0].flags = res[0].flags | (data.features.includes('PARTNERED') && ServerFlags.discord_partnered) | (data.features.includes('VERIFIED') && ServerFlags.verified)
|
||||
if(res[0].owners !== JSON.stringify([data.owner, ...data.admins]) || res[0].name !== data.name)
|
||||
await knex('servers').update({ name: data.name, owners: JSON.stringify([data.owner, ...data.admins]) })
|
||||
.where({ id: res[0].id })
|
||||
}
|
||||
delete res[0].owners
|
||||
// console.log(data)
|
||||
res[0].icon = data?.icon || null
|
||||
res[0].members = data?.memberCount || null
|
||||
res[0].emojis = data?.emojis || []
|
||||
res[0].category = JSON.parse(res[0].category)
|
||||
res[0].boostTier = data?.boostTier ?? null
|
||||
if(topLevel) {
|
||||
res[0].owner = await get._rawUser.load(data?.owner || '') || null
|
||||
res[0].bots = (await Promise.all(data?.bots.slice(0, 3).map(el => get._rawBot.load(el)) || [])).filter(el => el) || null
|
||||
} else {
|
||||
res[0].owner = data?.owner || null
|
||||
res[0].bots = data?.bots || null
|
||||
}
|
||||
}
|
||||
return res[0] ?? null
|
||||
}
|
||||
|
||||
async function fetchServerOwners(id: string): Promise<User[]|null> {
|
||||
const data = await getServerData(id)
|
||||
return data ? [ await get._rawUser.load(data.owner), ...(await Promise.all(data.admins.map(el => get._rawUser.load(el)))) ].filter(el => el) : null
|
||||
}
|
||||
|
||||
async function getServerData(id: string): Promise<ServerData|null> {
|
||||
return serialize((await Servers.findById(id))?.data || null)
|
||||
}
|
||||
|
||||
async function getUser(id: string, topLevel = true):Promise<User> {
|
||||
const res = await knex('users')
|
||||
.select(['id', 'flags', 'github'])
|
||||
.where({ id })
|
||||
if (res[0]) {
|
||||
const owned = await knex('bots')
|
||||
const ownedBots = await knex('bots')
|
||||
.select(['id'])
|
||||
.where('owners', 'like', `%${id}%`)
|
||||
const ownedServer = await knex('servers')
|
||||
.select(['id'])
|
||||
.where('owners', 'like', `%${id}%`)
|
||||
|
||||
const discordUser = await get.discord.user.load(id)
|
||||
res[0].tag = discordUser?.discriminator || '0000'
|
||||
res[0].username = discordUser?.username || 'Unknown User'
|
||||
if (bots) {
|
||||
res[0].bots = await Promise.all(owned.map(async b => await get._rawBot.load(b.id)))
|
||||
res[0].bots = res[0].bots.filter((el: Bot | null) => el).map((row: User) => ({ ...row }))
|
||||
if (topLevel) {
|
||||
res[0].bots = (await Promise.all(ownedBots.map(async b => await get._rawBot.load(b.id)))).filter((el: Bot | null) => el).map(row => ({ ...row }))
|
||||
res[0].servers = (await Promise.all(ownedServer.map(async b => await get._rawServer.load(b.id)))).filter((el: Server | null) => el).map(row => ({ ...row }))
|
||||
}
|
||||
else {
|
||||
res[0].bots = ownedBots.map(el => el.id)
|
||||
res[0].servers = ownedServer.map(el => el.id)
|
||||
}
|
||||
else res[0].bots = owned.map(el => el.id)
|
||||
}
|
||||
return res[0] || null
|
||||
}
|
||||
|
||||
async function getBotList(type: ListType, page = 1, query?: string):Promise<BotList> {
|
||||
async function getUserGuilds(id: string): Promise<Nullable<RawGuild[]>> {
|
||||
const token = await fetchUserDiscordToken(id)
|
||||
if(!token) return null
|
||||
const guilds = await fetch(DiscordEnpoints.Guilds, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
}
|
||||
}).then(r=> r.json())
|
||||
if(!Array.isArray(guilds)) return null
|
||||
return guilds
|
||||
}
|
||||
|
||||
async function getBotList(type: ListType, page = 1, query?: string):Promise<List<Bot>> {
|
||||
let res: { id: string }[]
|
||||
let count:string|number
|
||||
if (type === 'VOTE') {
|
||||
@ -142,7 +219,7 @@ async function getBotList(type: ListType, page = 1, query?: string):Promise<BotL
|
||||
.select(['id'])
|
||||
} else if (type === 'CATEGORY') {
|
||||
if (!query) throw new Error('쿼리가 누락되었습니다.')
|
||||
if (!categories.includes(query)) throw new Error('알 수 없는 카테고리입니다.')
|
||||
if (!botCategories.includes(query)) throw new Error('알 수 없는 카테고리입니다.')
|
||||
count = (
|
||||
await knex('bots')
|
||||
.where('category', 'like', `%${decodeURI(query)}%`).andWhereNot({ state: 'blocked' })
|
||||
@ -167,6 +244,83 @@ async function getBotList(type: ListType, page = 1, query?: string):Promise<BotL
|
||||
return { type, data: (await Promise.all(res.map(async el => await getBot(el.id)))).map(r=> ({...r})), currentPage: page, totalPage: Math.ceil(Number(count) / 16) }
|
||||
}
|
||||
|
||||
async function getServerList(type: ListType, page = 1, query?: string):Promise<List<Server>> {
|
||||
let res: { id: string }[]
|
||||
let count:string|number
|
||||
if (type === 'VOTE') {
|
||||
count = (await knex('servers').whereNot({ state: 'blocked' }).count())[0]['count(*)']
|
||||
res = await knex('servers')
|
||||
.orderBy('votes', 'desc')
|
||||
.limit(16)
|
||||
.offset(((page ? Number(page) : 1) - 1) * 16)
|
||||
.select(['id'])
|
||||
.whereNot({ state: 'blocked' })
|
||||
} else if (type === 'TRUSTED') {
|
||||
count = (
|
||||
await knex('servers')
|
||||
.whereRaw(`flags & ${ServerFlags.trusted}`)
|
||||
.count()
|
||||
.whereNot({ state: 'blocked' })
|
||||
)[0]['count(*)']
|
||||
res = await knex('servers').whereNot({ state: 'blocked' })
|
||||
.whereRaw(`flags & ${ServerFlags.trusted}`)
|
||||
.orderByRaw('RAND()')
|
||||
.limit(16)
|
||||
.offset(((page ? Number(page) : 1) - 1) * 16)
|
||||
.select(['id'])
|
||||
.whereNot({ state: 'blocked' })
|
||||
} else if (type === 'NEW') {
|
||||
count = (
|
||||
await knex('servers').whereNot({ state: 'blocked' })
|
||||
.count()
|
||||
)[0]['count(*)']
|
||||
res = await knex('servers')
|
||||
.orderBy('date', 'desc')
|
||||
.limit(16)
|
||||
.offset(((page ? Number(page) : 1) - 1) * 16)
|
||||
.select(['id'])
|
||||
.whereNot({ state: 'blocked' })
|
||||
} else if (type === 'PARTNERED') {
|
||||
count = (
|
||||
await knex('servers')
|
||||
.whereRaw(`flags & ${ServerFlags.partnered}`)
|
||||
.andWhereNot({ state: 'blocked' })
|
||||
.count()
|
||||
)[0]['count(*)']
|
||||
res = await knex('servers')
|
||||
.whereRaw(`flags & ${ServerFlags.partnered}`)
|
||||
.andWhereNot({ state: 'blocked' })
|
||||
.orderByRaw('RAND()')
|
||||
.limit(16)
|
||||
.offset(((page ? Number(page) : 1) - 1) * 16)
|
||||
.select(['id'])
|
||||
} else if (type === 'CATEGORY') {
|
||||
if (!query) throw new Error('쿼리가 누락되었습니다.')
|
||||
if (!serverCategories.includes(query)) throw new Error('알 수 없는 카테고리입니다.')
|
||||
count = (
|
||||
await knex('servers')
|
||||
.where('category', 'like', `%${decodeURI(query)}%`)
|
||||
.andWhereNot({ state: 'blocked' })
|
||||
.count()
|
||||
)[0]['count(*)']
|
||||
res = await knex('servers')
|
||||
.where('category', 'like', `%${decodeURI(query)}%`)
|
||||
.andWhereNot({ state: 'blocked' })
|
||||
.orderBy('votes', 'desc')
|
||||
.limit(16)
|
||||
.offset(((page ? Number(page) : 1) - 1) * 16)
|
||||
.select(['id'])
|
||||
} else if (type === 'SEARCH') {
|
||||
if (!query) throw new Error('쿼리가 누락되었습니다.')
|
||||
count = (await knex.raw('SELECT count(*) FROM servers WHERE MATCH(`name`, `intro`, `desc`) AGAINST(? in boolean mode)', [decodeURI(query)]))[0][0]['count(*)']
|
||||
res = (await knex.raw('SELECT id, votes, MATCH(`name`, `intro`, `desc`) AGAINST(? in boolean mode) as relevance FROM servers WHERE MATCH(`name`, `intro`, `desc`) AGAINST(? in boolean mode) ORDER BY relevance DESC, votes DESC LIMIT 16 OFFSET ?', [decodeURI(query), decodeURI(query), ((page ? Number(page) : 1) - 1) * 16]))[0]
|
||||
} else {
|
||||
count = 1
|
||||
res = []
|
||||
}
|
||||
return { type, data: (await Promise.all(res.map(async el => await getServer(el.id)))).map(r=> ({...r})), currentPage: page, totalPage: Math.ceil(Number(count) / 16) }
|
||||
}
|
||||
|
||||
async function getBotSubmit(id: string, date: number): Promise<SubmittedBot> {
|
||||
const res = await knex('submitted').select(['id', 'date', 'category', 'lib', 'prefix', 'intro', 'desc', 'url', 'web', 'git', 'discord', 'state', 'owners', 'reason']).where({ id, date })
|
||||
if(res.length === 0) return null
|
||||
@ -191,28 +345,44 @@ async function getBotSubmits(id: string): Promise<SubmittedBot[]> {
|
||||
* @param botID
|
||||
* @returns Timestamp
|
||||
*/
|
||||
async function getBotVote(userID: string, botID: string): Promise<number|null> {
|
||||
async function getVote(userID: string, targetID: string, type: 'bot' | 'server'): Promise<number|null> {
|
||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
||||
if(user.length === 0) return null
|
||||
const data = JSON.parse(user[0].votes)
|
||||
return data[botID] || 0
|
||||
|
||||
|
||||
return data[`${type}:${targetID}`] || 0
|
||||
}
|
||||
|
||||
async function voteBot(userID: string, botID: string): Promise<number|boolean> {
|
||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
||||
const key = `bot:${botID}`
|
||||
if(user.length === 0) return null
|
||||
const date = +new Date()
|
||||
const data = JSON.parse(user[0].votes)
|
||||
const lastDate = data[botID] || 0
|
||||
const lastDate = data[key] || 0
|
||||
if(date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
|
||||
data[botID] = date
|
||||
data[key] = date
|
||||
await knex('bots').where({ id: botID }).increment('votes', 1)
|
||||
await knex('users').where({ id: userID }).update({ votes: JSON.stringify(data) })
|
||||
const record = await Bots.updateOne({ _id: botID, 'voteMetrix.day': getYYMMDD() }, { $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } })
|
||||
if(record.n === 0) await Bots.findByIdAndUpdate(botID, { $push: { voteMetrix: { count: (await knex('bots').where({ id: botID }))[0].votes } } }, { upsert: true })
|
||||
return true
|
||||
}
|
||||
|
||||
async function voteServer(userID: string, serverID: string): Promise<number|boolean> {
|
||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
||||
const key = `server:${serverID}`
|
||||
if(user.length === 0) return null
|
||||
const date = +new Date()
|
||||
const data = JSON.parse(user[0].votes)
|
||||
const lastDate = data[key] || 0
|
||||
if(date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
|
||||
data[key] = date
|
||||
await knex('servers').where({ id: serverID }).increment('votes', 1)
|
||||
await knex('users').where({ id: userID }).update({ votes: JSON.stringify(data) })
|
||||
// const record = await Servers.updateOne({ _id: serverID, 'voteMetrix.day': getYYMMDD() }, { $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } })
|
||||
// if(record.n === 0) await Servers.findByIdAndUpdate(serverID, { $push: { voteMetrix: { count: (await knex('servers').where({ id: serverID }))[0].votes } } }, { upsert: true })
|
||||
return true
|
||||
}
|
||||
/**
|
||||
* @returns 1 - Has pending Bots
|
||||
* @returns 2 - Already submitted ID
|
||||
@ -251,23 +421,62 @@ async function submitBot(id: string, data: AddBotSubmit):Promise<1|2|3|4|Submitt
|
||||
return await getBotSubmit(botId, date)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns 1 - Server already exists
|
||||
* @returns 2 - Bot not invited
|
||||
* @returns 3 - Not owner
|
||||
* @returns 4 - Invalid invite code
|
||||
*/
|
||||
|
||||
async function submitServer(userID: string, id: string, data: AddServerSubmit): Promise<1|2|3|4|boolean> {
|
||||
const server = await get.server.load(id)
|
||||
if(server) return 1
|
||||
const serverData = await get.serverData(id)
|
||||
if(!serverData) return 2
|
||||
if(serverData.owner !== userID && !serverData.admins.includes(userID)) return 3
|
||||
const inviteData = await DiscordBot.fetchInvite(data.invite).catch(() => null)
|
||||
if(!inviteData || inviteData.guild.id !== id) return 4
|
||||
await knex('servers').insert({
|
||||
id: id,
|
||||
name: serverData.name,
|
||||
owners: JSON.stringify([ serverData.owner, ...serverData.admins ]),
|
||||
intro: data.intro,
|
||||
desc: data.desc,
|
||||
category: JSON.stringify(data.category),
|
||||
invite: data.invite,
|
||||
token: sign({ id }),
|
||||
})
|
||||
get.server.clear(id)
|
||||
return true
|
||||
}
|
||||
|
||||
async function getBotSpec(id: string, userID: string) {
|
||||
const res = await knex('bots').select(['id', 'token', 'webhook']).where({ id }).andWhere('owners', 'like', `%${userID}%`)
|
||||
if(!res[0]) return null
|
||||
return serialize(res[0])
|
||||
}
|
||||
|
||||
async function getServerSpec(id: string, userID: string): Promise<{ id: string, token: string }> {
|
||||
const res = await knex('servers').select(['id', 'token']).where({ id }).andWhere('owners', 'like', `%${userID}%`)
|
||||
if(!res[0]) return null
|
||||
return serialize(res[0])
|
||||
}
|
||||
|
||||
async function deleteBot(id: string): Promise<boolean> {
|
||||
const bot = await knex('bots').where({ id }).del()
|
||||
get.bot.clear(id)
|
||||
return !!bot
|
||||
}
|
||||
|
||||
async function updateBot(id: string, data: ManageBot) {
|
||||
async function deleteServer(id: string): Promise<boolean> {
|
||||
const server = await knex('servers').where({ id }).del()
|
||||
return !!server
|
||||
}
|
||||
|
||||
async function updateBot(id: string, data: ManageBot): Promise<number> {
|
||||
const res = await knex('bots').where({ id })
|
||||
if(res.length === 0 || res[0].state !== 'ok') return 0
|
||||
if(res.length === 0) return 0
|
||||
await knex('bots').update({
|
||||
id,
|
||||
prefix: data.prefix,
|
||||
lib: data.library,
|
||||
web: data.website,
|
||||
@ -282,6 +491,19 @@ async function updateBot(id: string, data: ManageBot) {
|
||||
return 1
|
||||
}
|
||||
|
||||
async function updatedServer(id: string, data: ManageServer) {
|
||||
const res = await knex('servers').where({ id })
|
||||
if(res.length === 0) return 0
|
||||
await knex('servers').update({
|
||||
invite: data.invite,
|
||||
category: JSON.stringify(data.category),
|
||||
intro: data.intro,
|
||||
desc: data.desc
|
||||
}).where({ id })
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns 1 - Limit of 100k servers
|
||||
* @returns 2 - Limit of 10M servers
|
||||
@ -293,6 +515,10 @@ async function updateServer(id: string, servers: number, shards: number) {
|
||||
else if(bot.servers < 1000000 && servers >= 1000000) return 2
|
||||
if(bot.shards < 200 && shards >= 200) return 3
|
||||
await knex('bots').update({ servers: servers === undefined ? bot.servers : servers, shards: shards === undefined ? bot.shards : shards }).where({ id })
|
||||
if(servers) {
|
||||
await Bots.findByIdAndUpdate(id, { $pull: { serverMetrix: { day: getYYMMDD() } } }, { upsert: true })
|
||||
await Bots.findByIdAndUpdate(id, { $push: { serverMetrix: { count: servers } } })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -314,6 +540,13 @@ async function resetBotToken(id: string, beforeToken: string) {
|
||||
return token
|
||||
}
|
||||
|
||||
async function resetServerToken(id: string, beforeToken: string) {
|
||||
const token = sign({ id })
|
||||
const server = await knex('servers').update({ token }).where({ id, token: beforeToken })
|
||||
if(server !== 1) return null
|
||||
return token
|
||||
}
|
||||
|
||||
async function Github(id: string, github: string) {
|
||||
const user = await knex('users').where({ github }).whereNot({ id })
|
||||
if(github && user.length !== 0) return 0
|
||||
@ -369,6 +602,37 @@ async function BotAuthorization(token: string):Promise<string|false> {
|
||||
else return bot[0].id
|
||||
}
|
||||
|
||||
async function ServerAuthorization(token: string): Promise<string|false> {
|
||||
const tokenInfo = verify(token ?? '')
|
||||
const server = await knex('servers').select(['id']).where({ id: tokenInfo?.id ?? '', token: token ?? '' })
|
||||
if(server.length === 0) return false
|
||||
else return server[0].id
|
||||
}
|
||||
|
||||
async function fetchUserDiscordToken(id: string): Promise<DiscordTokenInfo> {
|
||||
const res = await knex('users').select(['discord']).where({ id })
|
||||
let discord = verify(res[0]?.discord ?? '')
|
||||
if(!discord) return null
|
||||
if(Date.now() > (discord.iat + discord.expires_in) * 1000) {
|
||||
const token: DiscordTokenInfo = await fetch(DiscordEnpoints.Token, {
|
||||
method: 'POST',
|
||||
body: formData({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||
refresh_token: discord.refresh_token,
|
||||
grant_type: 'refresh_token'
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}).then(r=> r.json())
|
||||
if (token.error) return null
|
||||
await knex('users').update({ discord: sign({ access_token: token.access_token, expires_in: token.expires_in, refresh_token: token.refresh_token }) }).where({ id })
|
||||
discord = token
|
||||
}
|
||||
return discord
|
||||
}
|
||||
|
||||
async function addRequest(ip: string, map: TLRU<unknown, number>) {
|
||||
if(!map.has(ip)) map.set(ip, 0)
|
||||
map.set(ip, map.get(ip) + 1)
|
||||
@ -408,6 +672,22 @@ async function approveBotSubmission(id: string, date: number) {
|
||||
return true
|
||||
}
|
||||
|
||||
export function safeImageHost(text: string) {
|
||||
return text?.replace(markdownImage, (matches: string, alt: string|undefined, link: string|undefined, description: string|undefined): string => {
|
||||
try {
|
||||
const url = new URL(link)
|
||||
return `) ? link : camoUrl(link) })`
|
||||
} catch {
|
||||
return matches
|
||||
}
|
||||
}) || null
|
||||
}
|
||||
|
||||
async function viewBot(id: string) {
|
||||
const record = await Bots.updateOne({ _id: id, 'viewMetrix.day': getYYMMDD() }, { $inc: { 'viewMetrix.$.count': 1 } })
|
||||
if(record.n === 0) await Bots.findByIdAndUpdate(id, { $push: { viewMetrix: { count: 0 } } }, { upsert: true })
|
||||
}
|
||||
|
||||
export const get = {
|
||||
discord: {
|
||||
user: new DataLoader(
|
||||
@ -425,15 +705,16 @@ export const get = {
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
|
||||
botDescSafe: async (id: string) => {
|
||||
const bot = await get.bot.load(id)
|
||||
return bot?.desc.replace(markdownImage, (matches: string, alt: string|undefined, link: string|undefined, description: string|undefined): string => {
|
||||
try {
|
||||
const url = new URL(link)
|
||||
return `) ? link : camoUrl(link) })`
|
||||
} catch {
|
||||
return matches
|
||||
}
|
||||
}) || null
|
||||
return safeImageHost(bot?.desc)
|
||||
},
|
||||
server: new DataLoader(
|
||||
async (ids: string[]) =>
|
||||
(await Promise.all(ids.map(async (id: string) => await getServer(id)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 5000, maxAgeMs: 60000 }) }),
|
||||
_rawServer: new DataLoader(
|
||||
async (ids: string[]) =>
|
||||
(await Promise.all(ids.map(async (id: string) => await getServer(id, false)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 5000, maxAgeMs: 60000 }) }),
|
||||
user: new DataLoader(
|
||||
async (ids: string[]) =>
|
||||
(await Promise.all(ids.map(async (el: string) => await getUser(el)))).map(row => serialize(row))
|
||||
@ -442,6 +723,10 @@ export const get = {
|
||||
async (ids: string[]) =>
|
||||
(await Promise.all(ids.map(async (el: string) => await getUser(el, false)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
|
||||
userGuilds: new DataLoader(
|
||||
async (ids: string[]) =>
|
||||
(await Promise.all(ids.map(async (el: string) => await getUserGuilds(el)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
|
||||
botSubmits: new DataLoader(
|
||||
async (ids: string[]) =>
|
||||
(await Promise.all(ids.map(async (el: string) => await getBotSubmits(el)))).map(row => serialize(row))
|
||||
@ -454,6 +739,7 @@ export const get = {
|
||||
}))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
|
||||
botSpec: getBotSpec,
|
||||
serverSpec: getServerSpec,
|
||||
list: {
|
||||
category: new DataLoader(
|
||||
async (key: string[]) =>
|
||||
@ -483,37 +769,81 @@ export const get = {
|
||||
(await Promise.all(pages.map(async (page: number) => await getBotList('TRUSTED', page)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 3600000 }) }),
|
||||
},
|
||||
serverList: {
|
||||
category: new DataLoader(
|
||||
async (key: string[]) =>
|
||||
(await Promise.all(key.map(async (k: string) => {
|
||||
const json = JSON.parse(k)
|
||||
return await getServerList('CATEGORY', json.page, json.category)
|
||||
}))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 500000 }) }),
|
||||
search: new DataLoader(
|
||||
async (key: string[]) =>
|
||||
(await Promise.all(key.map(async (k: string) => {
|
||||
const json = JSON.parse(k)
|
||||
const res = await getServerList('SEARCH', json.page, json.query)
|
||||
return { ...res, totalPage: Number(res.totalPage), currentPage: Number(res.currentPage) }
|
||||
}))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 500000 }) }),
|
||||
votes: new DataLoader(
|
||||
async (pages: number[]) =>
|
||||
(await Promise.all(pages.map(async (page: number) => await getServerList('VOTE', page)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 500000 }) }),
|
||||
new: new DataLoader(
|
||||
async (pages: number[]) =>
|
||||
(await Promise.all(pages.map(async (page: number) => await getServerList('NEW', page)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 1800000 }) }),
|
||||
trusted: new DataLoader(
|
||||
async (pages: number[]) =>
|
||||
(await Promise.all(pages.map(async (page: number) => await getServerList('TRUSTED', page)))).map(row => serialize(row))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 3600000 }) }),
|
||||
},
|
||||
images: {
|
||||
user: new DataLoader(
|
||||
async (urls: string[]) =>
|
||||
(await Promise.all(urls.map(async (url: string) => await getImage(url))))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 500, maxAgeMs: 3600000 }) }),
|
||||
server: new DataLoader(
|
||||
async (urls: string[]) =>
|
||||
(await Promise.all(urls.map(async (url: string) => await getImage(url))))
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 500, maxAgeMs: 3600000 }) }),
|
||||
},
|
||||
botVote: getBotVote,
|
||||
serverData: getServerData,
|
||||
botVote: async (botID: string, targetID: string) => await getVote(botID, targetID, 'bot'),
|
||||
vote: getVote,
|
||||
Authorization,
|
||||
BotAuthorization,
|
||||
botSubmitList: getBotSubmitList
|
||||
ServerAuthorization,
|
||||
botSubmitList: getBotSubmitList,
|
||||
serverOwners: fetchServerOwners
|
||||
}
|
||||
|
||||
export const update = {
|
||||
assignToken,
|
||||
updateBotApplication,
|
||||
resetBotToken,
|
||||
resetServerToken,
|
||||
updateServer,
|
||||
Github,
|
||||
bot: updateBot,
|
||||
server: updatedServer,
|
||||
botOwners: updateOwner,
|
||||
denyBotSubmission,
|
||||
approveBotSubmission
|
||||
approveBotSubmission,
|
||||
fetchUserDiscordToken
|
||||
}
|
||||
|
||||
export const put = {
|
||||
voteBot,
|
||||
submitBot
|
||||
voteServer,
|
||||
submitBot,
|
||||
submitServer,
|
||||
viewBot
|
||||
}
|
||||
|
||||
export const remove = {
|
||||
bot: deleteBot
|
||||
bot: deleteBot,
|
||||
server: deleteServer
|
||||
}
|
||||
|
||||
export const ratelimit = {
|
||||
|
||||
@ -5,9 +5,10 @@ import { Readable } from 'stream'
|
||||
import cookie from 'cookie'
|
||||
import * as difflib from 'difflib'
|
||||
|
||||
import { BotFlags, ImageOptions, UserFlags } from '@types'
|
||||
import { BotFlags, ImageOptions, MetrixData, ServerFlags, UserFlags } from '@types'
|
||||
import Logger from '@utils/Logger'
|
||||
import { BASE_URLs, KoreanbotsEndPoints, Oauth } from '@utils/Constants'
|
||||
import Day from './Day'
|
||||
|
||||
export function handlePWA(): boolean {
|
||||
let displayMode = 'browser'
|
||||
@ -48,6 +49,10 @@ export function checkBotFlag(base: number, required: number | keyof typeof BotFl
|
||||
return checkFlag(base, typeof required === 'number' ? required : BotFlags[required])
|
||||
}
|
||||
|
||||
export function checkServerFlag(base: number, required: number | keyof typeof ServerFlags):boolean {
|
||||
return checkFlag(base, typeof required === 'number' ? required : ServerFlags[required])
|
||||
}
|
||||
|
||||
export function makeImageURL(root:string, { format='png', size=256 }:ImageOptions):string {
|
||||
return `${root}.${format}?size=${size}`
|
||||
}
|
||||
@ -56,9 +61,14 @@ export function makeBotURL({ id, vanity, flags=0 }: { flags?: number, vanity?:st
|
||||
return `/bots/${(checkBotFlag(flags, 'trusted') || checkBotFlag(flags, 'partnered')) && vanity ? vanity : id}`
|
||||
}
|
||||
|
||||
export function makeServerURL({ id, vanity, flags=0 }: { flags?: number, vanity?:string, id: string }): string {
|
||||
return `/servers/${(checkServerFlag(flags, 'trusted') || checkServerFlag(flags, 'partnered')) && vanity ? vanity : id}`
|
||||
}
|
||||
|
||||
export function makeUserURL({ id }: { id: string }): string {
|
||||
return `/users/${id}`
|
||||
}
|
||||
|
||||
export function serialize<T>(data: T): T {
|
||||
return JSON.parse(JSON.stringify(data))
|
||||
}
|
||||
@ -194,4 +204,15 @@ export function parseDockerhubTag(imageTag: string) {
|
||||
return imageTag?.split('/').pop().split(':').pop()
|
||||
}
|
||||
|
||||
export function getYYMMDD(): string {
|
||||
return (new Date()).toISOString().slice(0, 10).split('-').join('')
|
||||
}
|
||||
|
||||
export function convertMetrixToGraph(data: MetrixData[], keyname?: string) {
|
||||
return data.map(el=> ({
|
||||
x: Day(el.day, 'YYMMDD').toDate(),
|
||||
y: el[keyname] || el.count
|
||||
}))
|
||||
}
|
||||
|
||||
export * from './ShowdownExtensions'
|
||||
124
utils/Yup.ts
124
utils/Yup.ts
@ -1,7 +1,7 @@
|
||||
import * as Yup from 'yup'
|
||||
import YupKorean from 'yup-locales-ko'
|
||||
import { ListType } from '@types'
|
||||
import { categories, library, reportCats } from '@utils/Constants'
|
||||
import { botCategories, library, reportCats, serverCategories } from '@utils/Constants'
|
||||
import { HTTPProtocol, ID, Prefix, Url, Vanity } from '@utils/Regex'
|
||||
|
||||
Yup.setLocale(YupKorean)
|
||||
@ -79,6 +79,35 @@ interface WidgetOptions {
|
||||
type widgetType = 'votes' | 'servers' | 'status'
|
||||
type widgetExt = 'svg'
|
||||
|
||||
export const ServerWidgetOptionsSchema: Yup.SchemaOf<ServerWidgetOptions> = Yup.object({
|
||||
id: Yup.string().required(),
|
||||
ext: Yup.mixed<widgetExt>()
|
||||
.oneOf(['svg'])
|
||||
.required(),
|
||||
type: Yup.mixed<serverWidgetType>()
|
||||
.oneOf(['votes', 'members', 'boost'])
|
||||
.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 ServerWidgetOptions {
|
||||
id: string
|
||||
ext: widgetExt
|
||||
type: serverWidgetType
|
||||
scale: number
|
||||
style: 'flat' | 'classic'
|
||||
icon: boolean
|
||||
}
|
||||
|
||||
type serverWidgetType = 'votes' | 'members' | 'boost'
|
||||
export const PageCount = Yup.number()
|
||||
.integer()
|
||||
.positive()
|
||||
@ -91,7 +120,7 @@ export const OauthCallbackSchema: Yup.SchemaOf<OauthCallback> = Yup.object({
|
||||
export const botCategoryListArgumentSchema: Yup.SchemaOf<botCategoryListArgument> = Yup.object({
|
||||
page: PageCount,
|
||||
category: Yup.mixed()
|
||||
.oneOf(categories)
|
||||
.oneOf(botCategories)
|
||||
.required(),
|
||||
})
|
||||
|
||||
@ -100,6 +129,18 @@ interface botCategoryListArgument {
|
||||
category: string
|
||||
}
|
||||
|
||||
export const serverCategoryListArgumentSchema: Yup.SchemaOf<serverCategoryListArgument> = Yup.object({
|
||||
page: PageCount,
|
||||
category: Yup.mixed()
|
||||
.oneOf(serverCategories)
|
||||
.required(),
|
||||
})
|
||||
|
||||
interface serverCategoryListArgument {
|
||||
page: number
|
||||
category: string
|
||||
}
|
||||
|
||||
interface OauthCallback {
|
||||
code: string
|
||||
}
|
||||
@ -116,11 +157,13 @@ export const SearchQuerySchema: Yup.SchemaOf<SearchQuery> = Yup.object({
|
||||
.notRequired()
|
||||
.default(1)
|
||||
.label('페이지'),
|
||||
priority: Yup.mixed().oneOf(['bot', 'server']).notRequired()
|
||||
})
|
||||
|
||||
interface SearchQuery {
|
||||
q: string
|
||||
page: number
|
||||
page: number,
|
||||
priority?: 'bot' | 'server'
|
||||
}
|
||||
|
||||
export const AddBotSubmitSchema: Yup.SchemaOf<AddBotSubmit> = Yup.object({
|
||||
@ -158,7 +201,7 @@ export const AddBotSubmitSchema: Yup.SchemaOf<AddBotSubmit> = Yup.object({
|
||||
.min(2, '지원 디스코드는 최소 2자여야합니다.')
|
||||
.max(32, '지원 디스코드는 최대 32자까지만 가능합니다.')
|
||||
.nullable(),
|
||||
category: Yup.array(Yup.string().oneOf(categories))
|
||||
category: Yup.array(Yup.string().oneOf(botCategories))
|
||||
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
|
||||
.unique('카테고리는 중복될 수 없습니다.')
|
||||
.required('카테고리는 필수 항목입니다.'),
|
||||
@ -187,7 +230,41 @@ export interface AddBotSubmit {
|
||||
intro: string
|
||||
desc: string
|
||||
_csrf: string
|
||||
_captcha?: string
|
||||
_captcha: string
|
||||
}
|
||||
|
||||
export const AddServerSubmitSchema: Yup.SchemaOf<AddServerSubmit> = Yup.object({
|
||||
agree: Yup.boolean()
|
||||
.oneOf([true], '상단의 체크박스를 클릭해주세요.')
|
||||
.required('상단의 체크박스를 클릭해주세요.'),
|
||||
invite: Yup.string()
|
||||
.matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.')
|
||||
.min(2, '초대코드는 최소 2자여야합니다.')
|
||||
.max(32, '초대코드는 최대 32자까지만 가능합니다.')
|
||||
.required('초대코드는 필수 항목입니다.'),
|
||||
category: Yup.array(Yup.string().oneOf(serverCategories))
|
||||
.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(),
|
||||
_captcha: Yup.string().required()
|
||||
})
|
||||
export interface AddServerSubmit {
|
||||
agree: boolean
|
||||
invite: string
|
||||
category: string[]
|
||||
intro: string
|
||||
desc: string
|
||||
_csrf: string
|
||||
_captcha: string
|
||||
}
|
||||
|
||||
export const BotStatUpdateSchema: Yup.SchemaOf<BotStatUpdate> = Yup.object({
|
||||
@ -249,7 +326,7 @@ export const ManageBotSchema: Yup.SchemaOf<ManageBot> = Yup.object({
|
||||
.min(2, '지원 디스코드는 최소 2자여야합니다.')
|
||||
.max(32, '지원 디스코드는 최대 32자까지만 가능합니다.')
|
||||
.nullable(),
|
||||
category: Yup.array(Yup.string().oneOf(categories))
|
||||
category: Yup.array(Yup.string().oneOf(botCategories))
|
||||
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
|
||||
.unique('카테고리는 중복될 수 없습니다.')
|
||||
.required('카테고리는 필수 항목입니다.'),
|
||||
@ -277,6 +354,35 @@ export interface ManageBot {
|
||||
_csrf: string
|
||||
}
|
||||
|
||||
export const ManageServerSchema = Yup.object({
|
||||
invite: Yup.string()
|
||||
.matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.')
|
||||
.min(2, '초대코드는 최소 2자여야합니다.')
|
||||
.max(32, '초대코드는 최대 32자까지만 가능합니다.')
|
||||
.required('초대코드는 필수 항목입니다.'),
|
||||
category: Yup.array(Yup.string().oneOf(serverCategories))
|
||||
.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 ManageServer {
|
||||
invite: string
|
||||
category: string[]
|
||||
intro: string
|
||||
desc: string
|
||||
_csrf: string
|
||||
}
|
||||
|
||||
export const CsrfCaptchaSchema: Yup.SchemaOf<CsrfCaptcha> = Yup.object({
|
||||
_csrf: Yup.string().required(),
|
||||
_captcha: Yup.string().required()
|
||||
@ -301,12 +407,14 @@ export interface DeveloperBot {
|
||||
_csrf: string
|
||||
}
|
||||
|
||||
export const ResetBotTokenSchema: Yup.SchemaOf<ResetBotToken> = Yup.object({
|
||||
export const ResetTokenSchema: Yup.SchemaOf<ResetToken> = Yup.object({
|
||||
token: Yup.string().required(),
|
||||
_csrf: Yup.string().required(),
|
||||
})
|
||||
|
||||
export interface ResetBotToken {
|
||||
export const ResetBotTokenSchema = ResetTokenSchema
|
||||
|
||||
export interface ResetToken {
|
||||
token: string
|
||||
_csrf: string
|
||||
}
|
||||
|
||||
164
yarn.lock
164
yarn.lock
@ -1713,6 +1713,13 @@
|
||||
"@types/connect" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/bson@*":
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/bson/-/bson-4.0.4.tgz#79d2d26e81070044db2a1a8b2cc2f673c840e1e5"
|
||||
integrity sha512-awqorHvQS0DqxkHQ/FxcPX9E+H7Du51Qw/2F+5TBMSaE3G0hm+8D3eXJ6MAzFw75nE8V7xF0QvzUSdxIjJb/GA==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/connect@*":
|
||||
version "3.4.35"
|
||||
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
|
||||
@ -1842,6 +1849,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
|
||||
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
|
||||
|
||||
"@types/mongodb@^3.5.27":
|
||||
version "3.6.20"
|
||||
resolved "https://registry.yarnpkg.com/@types/mongodb/-/mongodb-3.6.20.tgz#b7c5c580644f6364002b649af1c06c3c0454e1d2"
|
||||
integrity sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==
|
||||
dependencies:
|
||||
"@types/bson" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/node-emoji@1.8.1":
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/node-emoji/-/node-emoji-1.8.1.tgz#689cb74fdf6e84309bcafce93a135dfecd01de3f"
|
||||
@ -1860,6 +1875,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.3.2.tgz#655432817f83b51ac869c2d51dd8305fb8342e16"
|
||||
integrity sha512-jJs9ErFLP403I+hMLGnqDRWT0RYKSvArxuBVh2veudHV7ifEC1WAmjJADacZ7mRbA2nWgHtn8xyECMAot0SkAw==
|
||||
|
||||
"@types/node@14.x || 15.x":
|
||||
version "15.14.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-15.14.4.tgz#aaf18436ef67f24676d92b8bbe0f5f41b08db3e8"
|
||||
integrity sha512-yblJrsfCxdxYDUa2fM5sP93ZLk5xL3/+3MJei+YtsNbIdY75ePy2AiCfpq+onepzax+8/Yv+OD/fLNleWpCzVg==
|
||||
|
||||
"@types/node@16.4.3":
|
||||
version "16.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.4.3.tgz#c01c1a215721f6dec71b47d88b4687463601ba48"
|
||||
@ -2665,6 +2685,19 @@ bindings@^1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path "1.0.0"
|
||||
|
||||
bl@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.1.tgz#8c11a7b730655c5d56898cdc871224f40fd901d5"
|
||||
integrity sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==
|
||||
dependencies:
|
||||
readable-stream "^2.3.5"
|
||||
safe-buffer "^5.1.1"
|
||||
|
||||
bluebird@3.5.1:
|
||||
version "3.5.1"
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
|
||||
integrity sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==
|
||||
|
||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
|
||||
version "4.12.0"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
|
||||
@ -2791,6 +2824,11 @@ bser@2.1.1:
|
||||
dependencies:
|
||||
node-int64 "^0.4.0"
|
||||
|
||||
bson@^1.1.4:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.6.tgz#fb819be9a60cd677e0853aee4ca712a785d6618a"
|
||||
integrity sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
|
||||
@ -3461,6 +3499,13 @@ debug@2, debug@^2.6.9:
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
|
||||
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@4, debug@4.3.2, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
|
||||
@ -3557,6 +3602,11 @@ delegates@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
|
||||
integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
|
||||
|
||||
denque@^1.4.1:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de"
|
||||
integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==
|
||||
|
||||
depd@^1.1.2, depd@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
|
||||
@ -5819,6 +5869,11 @@ jws@^3.2.2:
|
||||
jwa "^1.4.1"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
kareem@2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.2.tgz#78c4508894985b8d38a0dc15e1a8e11078f2ca93"
|
||||
integrity sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ==
|
||||
|
||||
kleur@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
||||
@ -6181,6 +6236,11 @@ memoize-one@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
memory-pager@^1.0.2:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5"
|
||||
integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==
|
||||
|
||||
merge-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
@ -6332,6 +6392,60 @@ module-details-from-path@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b"
|
||||
integrity sha1-EUyUlnPiqKNenTV4hSeqN7Z52is=
|
||||
|
||||
mongodb@3.6.10:
|
||||
version "3.6.10"
|
||||
resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.10.tgz#f10e990113c86b195c8af0599b9b3a90748b6ee4"
|
||||
integrity sha512-fvIBQBF7KwCJnDZUnFFy4WqEFP8ibdXeFANnylW19+vOwdjOAvqIzPdsNCEMT6VKTHnYu4K64AWRih0mkFms6Q==
|
||||
dependencies:
|
||||
bl "^2.2.1"
|
||||
bson "^1.1.4"
|
||||
denque "^1.4.1"
|
||||
optional-require "^1.0.3"
|
||||
safe-buffer "^5.1.2"
|
||||
optionalDependencies:
|
||||
saslprep "^1.0.0"
|
||||
|
||||
mongoose-legacy-pluralize@1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"
|
||||
integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==
|
||||
|
||||
mongoose@^5.13.4:
|
||||
version "5.13.4"
|
||||
resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.13.4.tgz#060a24fa17dd80a6c0bfad128d34616331477d8d"
|
||||
integrity sha512-D1yVHAOa+G8iQZsC/nNzZe+CI1FCYu6Qk384s1vSyaSfKCu/alKeyL78BA73SsxeRKT9zmswSIueLbGBURjrKg==
|
||||
dependencies:
|
||||
"@types/mongodb" "^3.5.27"
|
||||
"@types/node" "14.x || 15.x"
|
||||
bson "^1.1.4"
|
||||
kareem "2.3.2"
|
||||
mongodb "3.6.10"
|
||||
mongoose-legacy-pluralize "1.0.2"
|
||||
mpath "0.8.3"
|
||||
mquery "3.2.5"
|
||||
ms "2.1.2"
|
||||
optional-require "1.0.x"
|
||||
regexp-clone "1.0.0"
|
||||
safe-buffer "5.2.1"
|
||||
sift "13.5.2"
|
||||
sliced "1.0.1"
|
||||
|
||||
mpath@0.8.3:
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.8.3.tgz#828ac0d187f7f42674839d74921970979abbdd8f"
|
||||
integrity sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA==
|
||||
|
||||
mquery@3.2.5:
|
||||
version "3.2.5"
|
||||
resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.2.5.tgz#8f2305632e4bb197f68f60c0cffa21aaf4060c51"
|
||||
integrity sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==
|
||||
dependencies:
|
||||
bluebird "3.5.1"
|
||||
debug "3.1.0"
|
||||
regexp-clone "^1.0.0"
|
||||
safe-buffer "5.1.2"
|
||||
sliced "1.0.1"
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
@ -6744,6 +6858,18 @@ opentracing@>=0.12.1:
|
||||
resolved "https://registry.yarnpkg.com/opentracing/-/opentracing-0.14.5.tgz#891fa92cd90a24e64f99bc964370227310926c85"
|
||||
integrity sha512-XLKtEfHxqrWyF1fzxznsv78w3csW41ucHnjiKnfzZLD5FN8UBDZZL1i4q0FR29zjxXhm+2Hop+5Vr/b8tKIvEg==
|
||||
|
||||
optional-require@1.0.x:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.0.3.tgz#275b8e9df1dc6a17ad155369c2422a440f89cb07"
|
||||
integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==
|
||||
|
||||
optional-require@^1.0.3:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.1.1.tgz#214314d1f9fbdd6f9e28fb12c9a3b4422ef93fdc"
|
||||
integrity sha512-EnUe33GTAltyZlIsQ2l93KzBC9zi8BsxLvKP3wxALOsz/YIakVojyuZsv5PFFk8y8e6r+SbaPIsNmyPoSK0OHw==
|
||||
dependencies:
|
||||
require-at "^1.0.6"
|
||||
|
||||
optionator@^0.8.1:
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
|
||||
@ -7907,7 +8033,7 @@ readable-stream@1.1.x:
|
||||
isarray "0.0.1"
|
||||
string_decoder "~0.10.x"
|
||||
|
||||
readable-stream@2.3.7, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6:
|
||||
readable-stream@2.3.7, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6:
|
||||
version "2.3.7"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
|
||||
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
|
||||
@ -7992,6 +8118,11 @@ regenerator-transform@^0.14.2:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.8.4"
|
||||
|
||||
regexp-clone@1.0.0, regexp-clone@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-1.0.0.tgz#222db967623277056260b992626354a04ce9bf63"
|
||||
integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==
|
||||
|
||||
regexp.prototype.flags@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26"
|
||||
@ -8034,6 +8165,11 @@ regjsparser@^0.6.4:
|
||||
dependencies:
|
||||
jsesc "~0.5.0"
|
||||
|
||||
require-at@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/require-at/-/require-at-1.0.6.tgz#9eb7e3c5e00727f5a4744070a7f560d4de4f6e6a"
|
||||
integrity sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||
@ -8173,7 +8309,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||
|
||||
safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
|
||||
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
@ -8196,6 +8332,13 @@ sanitize-html@2.4.0:
|
||||
parse-srcset "^1.0.2"
|
||||
postcss "^8.0.2"
|
||||
|
||||
saslprep@^1.0.0:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226"
|
||||
integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==
|
||||
dependencies:
|
||||
sparse-bitfield "^3.0.3"
|
||||
|
||||
saxes@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d"
|
||||
@ -8331,6 +8474,11 @@ side-channel@^1.0.4:
|
||||
get-intrinsic "^1.0.2"
|
||||
object-inspect "^1.9.0"
|
||||
|
||||
sift@13.5.2:
|
||||
version "13.5.2"
|
||||
resolved "https://registry.yarnpkg.com/sift/-/sift-13.5.2.tgz#24a715e13c617b086166cd04917d204a591c9da6"
|
||||
integrity sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA==
|
||||
|
||||
signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
|
||||
@ -8362,6 +8510,11 @@ slice-ansi@^4.0.0:
|
||||
astral-regex "^2.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
|
||||
sliced@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41"
|
||||
integrity sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=
|
||||
|
||||
slide@~1.1.3:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
|
||||
@ -8447,6 +8600,13 @@ sourcemap-codec@^1.4.4:
|
||||
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||
|
||||
sparse-bitfield@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11"
|
||||
integrity sha1-/0rm5oZWBWuks+eSqzM004JzyhE=
|
||||
dependencies:
|
||||
memory-pager "^1.0.2"
|
||||
|
||||
spdx-compare@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/spdx-compare/-/spdx-compare-1.0.0.tgz#2c55f117362078d7409e6d7b08ce70a857cd3ed7"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user